Advertisement

← vibesdk

worker/services/rate-limit/rateLimits.ts

17.5 KB Download file
import { RateLimitType, RateLimitStore, RateLimitSettings, DORateLimitConfig, KVRateLimitConfig } from './config';
import { createObjectLogger } from '../../logger';
import { AuthUser } from '../../types/auth-types';
import { extractRequestMetadata } from '../../utils/authUtils';
import { captureSecurityEvent } from '../../observability/sentry';
import { KVRateLimitStore } from './KVRateLimitStore';
import { RateLimitResult } from './DORateLimitStore';
import { RateLimitExceededError, SecurityError } from 'shared/types/errors';
import { isDev } from 'worker/utils/envs';
import { AI_MODEL_CONFIG, AIModels } from 'worker/agents/inferutils/config.types';

interface LLMCallRateLimitOptions {
	creditCost?: number;
	throwOnExceeded?: boolean;
}

export class RateLimitService {
    static logger = createObjectLogger(this, 'RateLimitService');

    static buildRateLimitKey(
		rateLimitType: RateLimitType,
		identifier: string
	): string {
		return `platform:${rateLimitType}:${identifier}`;
	}

	static async getUserIdentifier(user: AuthUser): Promise<string> {
		return `user:${user.id}`;
	}

    static async getRequestIdentifier(request: Request): Promise<string> {
        // Anonymous requests key on network identity ONLY. Any bearer token here
        // is unverified (no signature/expiry/session check); trusting its hash lets
        // a client opt out of the IP bucket by rotating a random string per request.
        // Verified identity is keyed as user:${id} via getUniversalIdentifier.
        const metadata = extractRequestMetadata(request);
        return `ip:${metadata.ipAddress}`;
    }

    static async getUniversalIdentifier(user: AuthUser | null, request: Request): Promise<string> {
        if (user) {
            return this.getUserIdentifier(user);
        }
        return this.getRequestIdentifier(request);
    }

    /**
     * Durable Object-based rate limiting using bucketed sliding window algorithm
     * Provides better consistency and performance compared to KV
     */
    private static async enforceDORateLimit(
        env: Env,
        key: string,
        config: DORateLimitConfig,
        incrementBy: number = 1
    ): Promise<RateLimitResult> {
        try {
            const stub = env.DORateLimitStore.getByName(key);

            const result = await stub.increment(key, {
                limit: config.limit,
                period: config.period,
                burst: config.burst,
                burstWindow: config.burstWindow,
                bucketSize: config.bucketSize,
                dailyLimit: config.dailyLimit,
                calendarDaily: config.calendarDaily,
            }, incrementBy);

            return result;
        } catch (error) {
            this.logger.error('Failed to enforce DO rate limit', {
                key,
                error: error instanceof Error ? error.message : 'Unknown error'
            });
            return { success: true }; // Fail open
        }
    }
    
    static async enforce(
        env: Env,
        key: string,
        config: RateLimitSettings,
        limitType: RateLimitType,
        incrementBy: number = 1
    ): Promise<RateLimitResult> {
        const rateLimitConfig = config[limitType];
        
        // In dev mode, only skip binding-based rate limiters (they don't work locally)
        // DO-based rate limiting works locally
        if (isDev(env) && rateLimitConfig.store === RateLimitStore.RATE_LIMITER) {
            return { success: true };
        }
        
        switch (rateLimitConfig.store) {
            case RateLimitStore.RATE_LIMITER: {
                const result = await (env[rateLimitConfig.bindingName as keyof Env] as RateLimit).limit({ key });
                return { success: result.success };
            }
            case RateLimitStore.KV: {
                return await KVRateLimitStore.increment(env.VibecoderStore, key, rateLimitConfig as KVRateLimitConfig, incrementBy);
            }
            case RateLimitStore.DURABLE_OBJECT:
                return await this.enforceDORateLimit(env, key, rateLimitConfig as DORateLimitConfig, incrementBy);
            default:
                return { success: false };
        }
    }

    static async enforceGlobalApiRateLimit(
        env: Env,
        config: RateLimitSettings,
        user: AuthUser | null,
        request: Request
    ): Promise<void> {
        if (!config[RateLimitType.API_RATE_LIMIT].enabled) {
            return;
        }
        const identifier = await this.getUniversalIdentifier(user, request);

        const key = this.buildRateLimitKey(RateLimitType.API_RATE_LIMIT, identifier);
        
        try {
            const result = await this.enforce(env, key, config, RateLimitType.API_RATE_LIMIT);
            if (!result.success) {
                this.logger.warn('Global API rate limit exceeded', {
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent'),
                    ip: request.headers.get('CF-Connecting-IP')
                });
                captureSecurityEvent('rate_limit_exceeded', {
                    limitType: RateLimitType.API_RATE_LIMIT,
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent') || undefined,
                    ip: request.headers.get('CF-Connecting-IP') || undefined,
                });
                throw new RateLimitExceededError(`Global API rate limit exceeded`, RateLimitType.API_RATE_LIMIT);
            }
        } catch (error) {
            if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
                throw error;
            }
            this.logger.error('Failed to enforce global API rate limit', error);
        }
    }

    /**
     * Per-client limit for the unauthenticated public app discovery endpoints
     * (listing + detail). Layered on top of the global API limiter to make
     * bulk-harvest / scan attacks more expensive. Throws RateLimitExceededError
     * when the limit is exceeded.
     */
    static async enforcePublicAppsRateLimit(
        env: Env,
        config: RateLimitSettings,
        user: AuthUser | null,
        request: Request
    ): Promise<void> {
        if (!config[RateLimitType.PUBLIC_APPS].enabled) {
            return;
        }
        const identifier = await this.getUniversalIdentifier(user, request);

        const key = this.buildRateLimitKey(RateLimitType.PUBLIC_APPS, identifier);

        try {
            const result = await this.enforce(env, key, config, RateLimitType.PUBLIC_APPS);
            if (!result.success) {
                this.logger.warn('Public apps rate limit exceeded', {
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent'),
                    ip: request.headers.get('CF-Connecting-IP')
                });
                captureSecurityEvent('rate_limit_exceeded', {
                    limitType: RateLimitType.PUBLIC_APPS,
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent') || undefined,
                    ip: request.headers.get('CF-Connecting-IP') || undefined,
                });
                throw new RateLimitExceededError(`Public apps rate limit exceeded`, RateLimitType.PUBLIC_APPS);
            }
        } catch (error) {
            if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
                throw error;
            }
            this.logger.error('Failed to enforce public apps rate limit', error);
        }
    }

    /**
     * Per-preview-token rate limit for SpaceDO previews. These are dispatched
     * outside the Hono chain (via handleSpacePreview -> SpaceDO stub.fetch), so
     * the global API limiter never runs. `tokenId` should be an opaque,
     * non-reversible identifier for the preview token (e.g. a hash), never the
     * raw token. Throws RateLimitExceededError when the limit is exceeded.
     */
    static async enforceSpacePreviewRateLimit(
        env: Env,
        config: RateLimitSettings,
        tokenId: string,
        request: Request
    ): Promise<void> {
        if (!config[RateLimitType.SPACE_PREVIEW].enabled) {
            return;
        }
        const identifier = `preview:${tokenId}`;
        const key = this.buildRateLimitKey(RateLimitType.SPACE_PREVIEW, identifier);

        try {
            const result = await this.enforce(env, key, config, RateLimitType.SPACE_PREVIEW);
            if (!result.success) {
                this.logger.warn('Space preview rate limit exceeded', {
                    key,
                    userAgent: request.headers.get('User-Agent'),
                    ip: request.headers.get('CF-Connecting-IP'),
                });
                captureSecurityEvent('rate_limit_exceeded', {
                    limitType: RateLimitType.SPACE_PREVIEW,
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent') || undefined,
                    ip: request.headers.get('CF-Connecting-IP') || undefined,
                });
                throw new RateLimitExceededError(`Space preview rate limit exceeded`, RateLimitType.SPACE_PREVIEW);
            }
        } catch (error) {
            if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
                throw error;
            }
            this.logger.error('Failed to enforce space preview rate limit', error);
        }
    }

    static async enforceAuthRateLimit(
        env: Env,
        config: RateLimitSettings,
        user: AuthUser | null,
        request: Request
    ) {
        
        if (!config[RateLimitType.AUTH_RATE_LIMIT].enabled) {
            return;
        }
        const identifier = await this.getUniversalIdentifier(user, request);

        const key = this.buildRateLimitKey(RateLimitType.AUTH_RATE_LIMIT, identifier);
        
        try {
            const result = await this.enforce(env, key, config, RateLimitType.AUTH_RATE_LIMIT);
            if (!result.success) {
                this.logger.warn('Auth rate limit exceeded', {
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent'),
                    ip: request.headers.get('CF-Connecting-IP')
                });
                captureSecurityEvent('rate_limit_exceeded', {
                    limitType: RateLimitType.AUTH_RATE_LIMIT,
                    identifier,
                    key,
                    userAgent: request.headers.get('User-Agent') || undefined,
                    ip: request.headers.get('CF-Connecting-IP') || undefined,
                });
                throw new RateLimitExceededError(`Auth rate limit exceeded`, RateLimitType.AUTH_RATE_LIMIT);
            }
        } catch (error) {
            if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
                throw error;
            }
            this.logger.error('Failed to enforce auth rate limit', error);
        }
    }

	static async enforceAppCreationRateLimit(
		env: Env,
		config: RateLimitSettings,
		user: AuthUser,
		request: Request
	): Promise<void> {
		if (!config[RateLimitType.APP_CREATION].enabled) {
			return;
		}
		const identifier = await this.getUserIdentifier(user);

		const key = this.buildRateLimitKey(RateLimitType.APP_CREATION, identifier);
		
		try {
            const result = await this.enforce(env, key, config, RateLimitType.APP_CREATION);
			if (!result.success) {
				this.logger.warn('App creation rate limit exceeded', {
					identifier,
					key,
					exceededLimit: result.exceededLimit,
					limitValue: result.limitValue,
					userAgent: request.headers.get('User-Agent'),
					ip: request.headers.get('CF-Connecting-IP')
				});
				captureSecurityEvent('rate_limit_exceeded', {
					limitType: RateLimitType.APP_CREATION,
					identifier,
					key,
					exceededLimit: result.exceededLimit,
					userAgent: request.headers.get('User-Agent') || undefined,
					ip: request.headers.get('CF-Connecting-IP') || undefined,
				});

				// Build error message based on which limit was exceeded
				const limitValue = result.limitValue ?? config.appCreation.limit;
				const periodSeconds = result.periodSeconds ?? config.appCreation.period;
				const periodHours = periodSeconds / 3600;
				const periodLabel = result.exceededLimit === 'daily'
					? 'day'
					: `${periodHours} hour${periodHours >= 2 ? 's' : ''}`;

				throw new RateLimitExceededError(
					`App creation rate limit exceeded. Maximum ${limitValue} apps per ${periodLabel}`,
					RateLimitType.APP_CREATION,
					limitValue,
					periodSeconds,
                    ['Please try again later when the limit resets for you.']
				);
			}
		} catch (error) {
			if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
				throw error;
			}
			this.logger.error('Failed to enforce app creation rate limit', error);
		}
	}

	/**
	 * Get remaining credits for LLM calls without incrementing (for pre-flight checks)
	 * Works in both dev and prod - uses local DO in dev mode
	 */
	static async getRemainingCredits(
		env: Env,
		config: RateLimitSettings,
		userId: string
	): Promise<{ remaining: number; limit: number; dailyRemaining?: number; dailyLimit?: number }> {
		const identifier = `user:${userId}`;
		const key = this.buildRateLimitKey(RateLimitType.LLM_CALLS, identifier);
		const llmConfig = config[RateLimitType.LLM_CALLS] as DORateLimitConfig;

		try {
			const stub = env.DORateLimitStore.getByName(key);
			const remaining = await stub.getRemainingLimit(key, {
				limit: llmConfig.limit,
				period: llmConfig.period,
				dailyLimit: llmConfig.dailyLimit,
				bucketSize: llmConfig.bucketSize,
				calendarDaily: llmConfig.calendarDaily,
			});

			return {
				remaining,
				limit: llmConfig.limit,
				dailyLimit: llmConfig.dailyLimit,
			};
		} catch (error) {
			this.logger.error('Failed to get remaining credits', {
				key,
				error: error instanceof Error ? error.message : 'Unknown error'
			});
			// Fail open - return full limit
			return {
				remaining: llmConfig.limit,
				limit: llmConfig.limit,
				dailyLimit: llmConfig.dailyLimit,
			};
		}
	}

	/**
	 * Check if user is within free tier limits (without incrementing)
	 */
	static async isWithinLimits(
		env: Env,
		config: RateLimitSettings,
		userId: string
	): Promise<boolean> {
		const { remaining } = await this.getRemainingCredits(env, config, userId);
		return remaining > 0;
	}

	static async enforceLLMCallsRateLimit(
        env: Env,
		config: RateLimitSettings,
		userId: string,
        model: AIModels | string,
        suffix: string = "",
		isUsingBYOK: boolean = false,
		hasCloudflareConfigured: boolean = false,
		options: LLMCallRateLimitOptions = {},
	): Promise<void> {

		const llmConfig = config[RateLimitType.LLM_CALLS];
		if (!llmConfig.enabled) {
			return;
		}

		// Skip rate limiting for BYOK users if configured
		if (isUsingBYOK && llmConfig.excludeBYOKUsers) {
			this.logger.debug('Skipping rate limit for BYOK user', { userId });
			return;
		}

		// Skip rate limiting for Cloudflare-connected users if configured
		if (hasCloudflareConfigured && llmConfig.excludeCloudflareConnected) {
			this.logger.debug('Skipping rate limit for Cloudflare-connected user', { userId });
			return;
		}

		const identifier = `user:${userId}`;
		
		const key = this.buildRateLimitKey(RateLimitType.LLM_CALLS, `${identifier}${suffix}`);
		
		try {
			const incrementBy = options.creditCost ?? AI_MODEL_CONFIG[model as AIModels]?.creditCost;
			if (!incrementBy || incrementBy <= 0) {
				return;
			}

			const result = await this.enforce(env, key, config, RateLimitType.LLM_CALLS, incrementBy);
			if (!result.success && options.throwOnExceeded !== false) {
				this.logger.warn('LLM calls rate limit exceeded', {
					identifier,
					key,
					exceededLimit: result.exceededLimit,
					limitValue: result.limitValue,
                    model,
                    incrementBy
				});
				captureSecurityEvent('rate_limit_exceeded', {
					limitType: RateLimitType.LLM_CALLS,
					identifier,
					key,
					exceededLimit: result.exceededLimit,
                    model,
                    incrementBy
				});

				// Build error message based on which limit was exceeded
				const limitValue = result.limitValue ?? config.llmCalls.limit;
				const periodSeconds = result.periodSeconds ?? config.llmCalls.period;
				const periodHours = periodSeconds / 3600;
				const periodLabel = result.exceededLimit === 'daily'
					? 'day'
					: `${periodHours} hour${periodHours >= 2 ? 's' : ''}`;

				throw new RateLimitExceededError(
					`AI inference rate limit exceeded. Consider using lighter models. Maximum ${limitValue} credits per ${periodLabel}.`,
					RateLimitType.LLM_CALLS,
					limitValue,
					periodSeconds,
                    [`Please try again later when the limit resets for you. The current model costs ${incrementBy} credits per call. Please go to settings to change your default model.`]
				);
			}
		} catch (error) {
			if (error instanceof RateLimitExceededError || error instanceof SecurityError) {
				throw error;
			}
			this.logger.error('Failed to enforce LLM calls rate limit', error);
		}
	}
}