fix(api): usage and keys (#2092)

Integrated into release/v3.8.0
This commit is contained in:
Yoviar Pauzi
2026-05-10 08:04:07 +07:00
committed by GitHub
parent f5e155b7c8
commit bfb5ab0f58
20 changed files with 904 additions and 88 deletions

View File

@@ -14,6 +14,13 @@ import { checkBudget } from "@/domain/costRules";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
const DEFAULT_RATE_LIMITS: RateLimitRule[] = [
{ limit: 1000, window: 86400 }, // 1000 per day
{ limit: 5000, window: 604800 }, // 5000 per week
{ limit: 20000, window: 2592000 } // 20000 per month
];
interface AccessSchedule {
enabled: boolean;
@@ -34,10 +41,13 @@ export interface ApiKeyMetadata {
budget?: number;
usedBudget?: number;
isActive?: boolean;
isBanned?: boolean;
expiresAt?: string | null;
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
maxSessions?: number | null;
rateLimits?: RateLimitRule[] | null;
}
/**
@@ -106,64 +116,7 @@ function isWithinSchedule(schedule: AccessSchedule): boolean {
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
// ── In-memory request counter for per-key rate limits (#452) ──
/** Sliding-window request timestamps per API key */
const _requestTimestamps = new Map<string, number[]>();
const REQUEST_COUNTER_MAX_KEYS = 5000;
const REQUEST_DAY_MS = 24 * 60 * 60 * 1000;
const REQUEST_MINUTE_MS = 60 * 1000;
/** Record a request and check per-key limits. Returns null if OK, or an error message. */
function checkRequestCountLimits(
apiKeyId: string,
maxPerDay: number | null | undefined,
maxPerMinute: number | null | undefined
): string | null {
if (!maxPerDay && !maxPerMinute) return null;
const now = Date.now();
// Get or create timestamp array for this key
let timestamps = _requestTimestamps.get(apiKeyId);
if (!timestamps) {
timestamps = [];
_requestTimestamps.set(apiKeyId, timestamps);
// Prevent unbounded growth
if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) {
const firstKey = _requestTimestamps.keys().next().value;
if (firstKey) _requestTimestamps.delete(firstKey);
}
}
// Prune timestamps older than 24h
const dayAgo = now - REQUEST_DAY_MS;
while (timestamps.length > 0 && timestamps[0] < dayAgo) {
timestamps.shift();
}
// Check per-minute limit (before recording this request)
if (maxPerMinute && maxPerMinute > 0) {
const minuteAgo = now - REQUEST_MINUTE_MS;
const recentCount = timestamps.filter((t) => t >= minuteAgo).length;
if (recentCount >= maxPerMinute) {
return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`;
}
}
// Check per-day limit
if (maxPerDay && maxPerDay > 0) {
if (timestamps.length >= maxPerDay) {
return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil(
(timestamps[0] + REQUEST_DAY_MS - now) / 60000
)} minutes.`;
}
}
// All checks passed — record this request
timestamps.push(now);
return null;
}
// Legacy in-memory request counter has been replaced by Redis-backed multi-window rate limiter
export interface ApiKeyPolicyResult {
/** API key string (null if no key provided) */
@@ -222,7 +175,7 @@ export async function enforceApiKeyPolicy(
return { apiKey, apiKeyInfo: null, rejection: null };
}
// ── Check 1: is_active — hard block regardless of schedule ──
// ── Check 1: is_active / is_banned ──
if (apiKeyInfo.isActive === false) {
return {
apiKey,
@@ -230,6 +183,25 @@ export async function enforceApiKeyPolicy(
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
};
}
if (apiKeyInfo.isBanned === true) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations"),
};
}
// ── Check 1.5: expires_at ──
if (apiKeyInfo.expiresAt) {
const expiry = new Date(apiKeyInfo.expiresAt).getTime();
if (Date.now() > expiry) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired"),
};
}
}
// ── Check 2: access_schedule — time-based access window ──
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
@@ -286,18 +258,31 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 5: Request-count limits (#452) ──
if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) {
const limitError = checkRequestCountLimits(
apiKeyInfo.id,
apiKeyInfo.maxRequestsPerDay,
apiKeyInfo.maxRequestsPerMinute
);
if (limitError) {
// ── Check 5: Generic Multi-Window Rate Limits ──
if (apiKeyInfo.id) {
const rulesToApply = (apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0)
? [...apiKeyInfo.rateLimits]
: [...DEFAULT_RATE_LIMITS];
// Combine with legacy limits if they exist and custom rate limits aren't set
if (!apiKeyInfo.rateLimits || apiKeyInfo.rateLimits.length === 0) {
if (apiKeyInfo.maxRequestsPerDay) {
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 });
}
if (apiKeyInfo.maxRequestsPerMinute) {
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 });
}
}
const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply);
if (!rateLimitResult.allowed) {
const failedWindowStr = rateLimitResult.failedWindow
? ` (${rateLimitResult.failedWindow}s window)`
: "";
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError),
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, `Request limit exceeded${failedWindowStr}. Please try again later.`),
};
}
}

View File

@@ -0,0 +1,142 @@
import Redis from "ioredis";
// Reuse existing REDIS_URL if set, or local redis via default docker-compose
// Use REDIS_URL from env (Docker/Production) or fallback to local redis
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
if (process.env.NODE_ENV === 'production' && !process.env.REDIS_URL) {
console.warn('[REDIS] REDIS_URL is not set in production. Falling back to default.');
}
let redisClient: Redis | null = null;
export function getRedisClient() {
if (!redisClient) {
redisClient = new Redis(REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: false,
retryStrategy(times) {
return Math.min(times * 50, 2000); // Exponential backoff
}
});
redisClient.on('error', (err) => console.error('[REDIS] Error:', err.message));
}
return redisClient;
}
export interface RateLimitRule {
limit: number;
window: number; // in seconds
}
export interface RateLimitResult {
allowed: boolean;
failedWindow?: number;
}
/**
* Atomic Lua script for multi-rule rate limiting using fixed window.
* Returns {1, 0} if allowed, or {0, failedWindow} if rejected.
*/
const RATE_LIMIT_SCRIPT = `
local key_prefix = KEYS[1]
local current_time = tonumber(ARGV[1])
local rules = {}
for i = 2, #ARGV, 2 do
table.insert(rules, {
limit = tonumber(ARGV[i]),
window = tonumber(ARGV[i+1])
})
end
-- First pass: check if any limit is exceeded
for i, rule in ipairs(rules) do
local current_window = math.floor(current_time / rule.window)
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
local count = tonumber(redis.call("GET", window_key) or "0")
if count >= rule.limit then
return { 0, rule.window } -- Reject, return which window failed
end
end
-- Second pass: increment all rules
for i, rule in ipairs(rules) do
local current_window = math.floor(current_time / rule.window)
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
local count = redis.call("INCR", window_key)
if count == 1 then
-- TTL is twice the window size to ensure it covers the current window safely
redis.call("EXPIRE", window_key, rule.window * 2)
end
end
return { 1, 0 } -- Accepted
`;
const TEST_MEMORY_STORE = new Map<string, number>();
let explicitTestMode = false;
export function setRateLimiterTestMode(enabled: boolean) {
explicitTestMode = enabled;
if (enabled) TEST_MEMORY_STORE.clear();
}
/**
* Checks multi-window rate limits for an API key atomically via Redis.
*/
export async function checkRateLimit(
keyId: string,
rules: RateLimitRule[]
): Promise<RateLimitResult> {
if (!rules || rules.length === 0) return { allowed: true };
// ── In-memory mock for unit tests ──
const isTestMode = explicitTestMode || process.env.NODE_ENV === "test" || process.env.DISABLE_SQLITE_AUTO_BACKUP === "true";
if (isTestMode) {
const now = Math.floor(Date.now() / 1000);
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
if (count >= rule.limit) {
return { allowed: false, failedWindow: rule.window };
}
}
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
}
return { allowed: true };
}
const redis = getRedisClient();
const args: (string | number)[] = [Math.floor(Date.now() / 1000)];
for (const rule of rules) {
args.push(rule.limit, rule.window);
}
try {
const result = await redis.eval(
RATE_LIMIT_SCRIPT,
1,
`rl:api_key:${keyId}`,
...args
) as [number, number];
if (result[0] === 0) {
return { allowed: false, failedWindow: result[1] };
}
return { allowed: true };
} catch (error) {
// Fail-open strategy if Redis goes down to prevent complete API outage
console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error);
return { allowed: true };
}
}

View File

@@ -1465,8 +1465,11 @@ export const updateKeyPermissionsSchema = z
noLog: z.boolean().optional(),
autoResolve: z.boolean().optional(),
isActive: z.boolean().optional(),
isBanned: z.boolean().optional(),
expiresAt: z.string().datetime().nullable().optional(),
maxSessions: z.number().int().min(0).max(10000).optional(),
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
rateLimits: z.union([z.array(z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })).max(50), z.null()]).optional(),
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
})
.superRefine((value, ctx) => {
@@ -1477,8 +1480,11 @@ export const updateKeyPermissionsSchema = z
value.noLog === undefined &&
value.autoResolve === undefined &&
value.isActive === undefined &&
value.isBanned === undefined &&
value.expiresAt === undefined &&
value.maxSessions === undefined &&
value.accessSchedule === undefined &&
value.rateLimits === undefined
value.scopes === undefined
) {
ctx.addIssue({