OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / rate-limiter.js

273 lines · javascript ref:
/**
 * RateLimiter
 *
 * Token bucket rate limiting with persistent storage:
 * - Per-IP rate limiting
 * - Per-identity rate limiting
 * - Configurable limits per operation type
 * - Automatic cleanup of expired buckets
 * - Persistent state in Hyperbee
 */
export class RateLimiter {
  constructor(options = {}) {
    this.limits = options.limits || {
      // Format: { tokens, refillRate, refillInterval }
      'auth': { tokens: 10, refillRate: 2, refillInterval: 60 * 1000 },  // 10 attempts, +2/min
      'api': { tokens: 100, refillRate: 20, refillInterval: 60 * 1000 }, // 100 requests, +20/min
      'git': { tokens: 50, refillRate: 10, refillInterval: 60 * 1000 },  // 50 git ops, +10/min
      'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 } // 5 generates, +1/hr
    }

    // Persistent storage (optional)
    this.db = options.db || null

    // In-memory cache for performance
    this.cache = new Map()
    this.cacheTimeout = options.cacheTimeout || 10 * 1000  // 10 seconds

    // Cleanup expired buckets every 5 minutes
    this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000)
  }

  /**
   * Check if request is allowed
   * @param {string} key - Unique key (IP or identityId)
   * @param {string} operation - Operation type (auth, api, git)
   * @param {number} cost - Token cost (default 1)
   * @returns {Promise<Object>} { allowed, remaining, resetAt }
   */
  async check(key, operation = 'api', cost = 1) {
    const limit = this.limits[operation]
    if (!limit) {
      console.warn(`[RateLimit] Unknown operation: ${operation}`)
      return { allowed: true, remaining: Infinity, resetAt: null }
    }

    const bucketKey = `${operation}:${key}`

    // Try cache first
    let bucket = this.cache.get(bucketKey)
    const now = Date.now()

    // Load from storage if not in cache or cache expired
    if (!bucket || (bucket.cachedAt && now - bucket.cachedAt > this.cacheTimeout)) {
      bucket = await this.loadBucket(bucketKey, limit)
      bucket.cachedAt = now
      this.cache.set(bucketKey, bucket)
    }

    // Refill tokens based on time elapsed
    const elapsed = now - bucket.lastRefill
    const refills = Math.floor(elapsed / bucket.limit.refillInterval)

    if (refills > 0) {
      bucket.tokens = Math.min(
        bucket.limit.tokens,
        bucket.tokens + (refills * bucket.limit.refillRate)
      )
      bucket.lastRefill = now
    }

    // Check if enough tokens
    if (bucket.tokens >= cost) {
      bucket.tokens -= cost

      // Update cache and storage
      this.cache.set(bucketKey, bucket)
      await this.saveBucket(bucketKey, bucket)

      return {
        allowed: true,
        remaining: bucket.tokens,
        resetAt: bucket.lastRefill + bucket.limit.refillInterval
      }
    }

    // Rate limited
    return {
      allowed: false,
      remaining: 0,
      resetAt: bucket.lastRefill + bucket.limit.refillInterval,
      retryAfter: Math.ceil((bucket.limit.refillInterval - elapsed) / 1000)
    }
  }

  /**
   * Load bucket from storage
   * @param {string} bucketKey - Bucket key
   * @param {Object} limit - Limit configuration
   * @returns {Promise<Object>} Bucket
   */
  async loadBucket(bucketKey, limit) {
    if (!this.db) {
      // No persistent storage - create new bucket
      return {
        tokens: limit.tokens,
        lastRefill: Date.now(),
        limit
      }
    }

    try {
      const entry = await this.db.get(`ratelimit:${bucketKey}`)
      if (entry && entry.value) {
        return {
          ...entry.value,
          limit  // Restore limit config
        }
      }
    } catch (err) {
      console.warn('[RateLimit] Failed to load bucket:', err.message)
    }

    // Create new bucket
    return {
      tokens: limit.tokens,
      lastRefill: Date.now(),
      limit
    }
  }

  /**
   * Save bucket to storage
   * @param {string} bucketKey - Bucket key
   * @param {Object} bucket - Bucket data
   * @returns {Promise<void>}
   */
  async saveBucket(bucketKey, bucket) {
    if (!this.db) return

    try {
      // Don't save limit config (restore from constructor)
      const { limit, cachedAt, ...data } = bucket
      await this.db.put(`ratelimit:${bucketKey}`, data)
    } catch (err) {
      console.warn('[RateLimit] Failed to save bucket:', err.message)
    }
  }

  /**
   * Reset bucket for key
   * @param {string} key - Unique key
   * @param {string} operation - Operation type
   */
  async reset(key, operation) {
    const bucketKey = `${operation}:${key}`

    // Clear from cache
    this.cache.delete(bucketKey)

    // Delete from storage
    if (this.db) {
      try {
        await this.db.del(`ratelimit:${bucketKey}`)
      } catch (err) {
        console.warn('[RateLimit] Failed to delete bucket:', err.message)
      }
    }
  }

  /**
   * Cleanup stale buckets (no activity for 1 hour)
   */
  async cleanup() {
    const now = Date.now()
    const staleThreshold = 60 * 60 * 1000  // 1 hour
    let cleaned = 0

    // Clean cache
    for (const [key, bucket] of this.cache.entries()) {
      if (now - bucket.lastRefill > staleThreshold) {
        this.cache.delete(key)
        cleaned++
      }
    }

    // Clean storage
    if (this.db) {
      try {
        const toDelete = []

        for await (const entry of this.db.createReadStream({
          gte: 'ratelimit:',
          lt: 'ratelimit:\xff'
        })) {
          const bucket = entry.value
          if (bucket && now - bucket.lastRefill > staleThreshold) {
            toDelete.push(entry.key)
          }
        }

        for (const key of toDelete) {
          await this.db.del(key)
          cleaned++
        }
      } catch (err) {
        console.warn('[RateLimit] Cleanup error:', err.message)
      }
    }

    if (cleaned > 0) {
      console.log(`[RateLimit] Cleaned up ${cleaned} stale buckets`)
    }
  }

  /**
   * Get stats
   */
  async stats() {
    const byOperation = {}
    let total = 0

    // Count from cache
    for (const [key, bucket] of this.cache.entries()) {
      const operation = key.split(':')[0]
      if (!byOperation[operation]) {
        byOperation[operation] = { count: 0, totalTokens: 0 }
      }
      byOperation[operation].count++
      byOperation[operation].totalTokens += bucket.tokens
      total++
    }

    // Count from storage (if available)
    if (this.db) {
      try {
        for await (const entry of this.db.createReadStream({
          gte: 'ratelimit:',
          lt: 'ratelimit:\xff'
        })) {
          const key = entry.key.replace('ratelimit:', '')
          const operation = key.split(':')[0]

          // Skip if already counted in cache
          if (this.cache.has(key)) continue

          if (!byOperation[operation]) {
            byOperation[operation] = { count: 0, totalTokens: 0 }
          }
          byOperation[operation].count++
          byOperation[operation].totalTokens += (entry.value.tokens || 0)
          total++
        }
      } catch (err) {
        console.warn('[RateLimit] Stats error:', err.message)
      }
    }

    return {
      total,
      cached: this.cache.size,
      byOperation
    }
  }

  /**
   * Destroy limiter and stop cleanup
   */
  destroy() {
    clearInterval(this.cleanupInterval)
    this.cache.clear()
  }
}