/**
* 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()
}
}