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

@@ -54,6 +54,12 @@ STORAGE_ENCRYPTION_KEY_VERSION=v1
# Default: false (backups enabled) | Set true to skip backup on every restart.
DISABLE_SQLITE_AUTO_BACKUP=false
# ── Redis (Rate Limiting) ──
# Redis connection URL for the rate limiter backend.
# Used by: src/shared/utils/rateLimiter.ts
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
REDIS_URL=redis://localhost:6379
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS

View File

@@ -12,8 +12,25 @@
# ──────────────────────────────────────────────────────────────────────
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
image: redis:8.6.2
container_name: omniroute-redis-prod
restart: unless-stopped
volumes:
- redis-prod-data:/data
command: redis-server --save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
omniroute-prod:
container_name: omniroute-prod
depends_on:
redis:
condition: service_healthy
build:
context: .
target: runner-cli
@@ -44,3 +61,5 @@ services:
volumes:
omniroute-prod-data:
name: omniroute-prod-data
redis-prod-data:
name: redis-prod-data

View File

@@ -28,6 +28,7 @@ x-common: &common
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-0.0.0.0}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
volumes:
- ./data:/app/data
healthcheck:
@@ -38,6 +39,22 @@ x-common: &common
start_period: 15s
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
image: redis:8.6.2
container_name: omniroute-redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
# ── Profile: base (minimal, no CLI tools) ──────────────────────────
omniroute-base:
<<: *common
@@ -138,3 +155,5 @@ services:
volumes:
cliproxyapi-data:
name: cliproxyapi-data
redis-data:
name: omniroute-redis-data

88
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"fuse.js": "^7.3.0",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.10.1",
"isomorphic-dompurify": "^3.12.0",
"jose": "^6.2.3",
"js-yaml": "^4.1.1",
@@ -2084,6 +2085,12 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -6329,6 +6336,15 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7346,6 +7362,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -9576,6 +9601,30 @@
"integrity": "sha512-Ocd1vPuD68rW6BJDuAOtnnc1GPeVepY5kZXML1psGVFQ+1Q8CfkftT3Tnam+Mxx97Pz08jIEDCotl/GV+Naccg==",
"license": "MIT"
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz",
@@ -10995,6 +11044,18 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -13392,6 +13453,27 @@
"node": ">=8"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -14321,6 +14403,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",

View File

@@ -125,6 +125,7 @@
"fuse.js": "^7.3.0",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.10.1",
"isomorphic-dompurify": "^3.12.0",
"jose": "^6.2.3",
"js-yaml": "^4.1.1",

View File

@@ -0,0 +1,34 @@
import Database from 'better-sqlite3';
import path from 'path';
import os from 'os';
const SQLITE_FILE = path.join(process.cwd(), 'data', 'storage.sqlite');
console.log('Checking database at:', SQLITE_FILE);
const db = new Database(SQLITE_FILE, { readonly: true });
try {
const rows = db.prepare(`
SELECT api_key_id, api_key_name, COUNT(*) as count, MAX(timestamp) as last_used
FROM usage_history
GROUP BY api_key_id, api_key_name
ORDER BY count DESC
`).all();
console.log('Top Usage Entries:');
console.table(rows);
const keys = db.prepare(`
SELECT id, name, key_prefix, machine_id
FROM api_keys
`).all();
console.log('All API Keys:');
console.table(keys);
} catch (err) {
console.error('Error:', err.message);
} finally {
db.close();
}

View File

@@ -69,8 +69,11 @@ interface ApiKey {
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
isBanned?: boolean;
expiresAt?: string | null;
maxSessions?: number;
accessSchedule?: AccessSchedule | null;
rateLimits?: Array<{ limit: number; window: number }> | null;
scopes?: string[];
createdAt: string;
}
@@ -177,28 +180,24 @@ export default function ApiManagerPageClient() {
fetch("/api/usage/analytics?range=all"),
fetch("/api/usage/call-logs?limit=1000"),
]);
const analytics = analyticsRes.ok ? await analyticsRes.json() : null;
const byApiKey: any[] = analytics?.byApiKey || [];
const logs = logsRes.ok ? await logsRes.json() : [];
const stats: Record<string, KeyUsageStats> = {};
for (const key of apiKeys) {
const analyticsMatch = byApiKey.find(
(entry: any) =>
entry.apiKeyId === key.id || (!entry.apiKeyId && entry.apiKeyName === key.name)
);
// Match analytics entry by unique API Key ID (isolates usage to this specific key instance)
const matches = byApiKey.filter((entry: any) => entry.apiKeyId === key.id);
const totalRequests = matches.reduce((sum: number, entry: any) => sum + (Number(entry.requests) || 0), 0);
// The call-logs endpoint returns entries sorted by timestamp DESC,
// so the first match is the most recent one.
// Match call logs by unique ID as well for the lastUsed timestamp
const lastUsed =
(logs || []).find((log: any) => log.apiKeyId === key.id)?.timestamp || null;
(logs || []).find(
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
)?.timestamp || null;
stats[key.id] = {
totalRequests: analyticsMatch?.requests ?? 0,
totalRequests,
lastUsed,
};
}
@@ -273,7 +272,6 @@ export default function ApiManagerPageClient() {
};
const handleDeleteKey = async (id: string) => {
// Validate ID format to prevent injection
if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) {
setPageError(t("invalidKeyId"));
return;
@@ -300,6 +298,30 @@ export default function ApiManagerPageClient() {
}
};
const handleRegenerateKey = async (id: string) => {
if (!id) return;
if (!confirm(t("regenerateConfirm"))) return;
setIsSubmitting(true);
clearPageError();
try {
const res = await fetch(`/api/keys/${encodeURIComponent(id)}/regenerate`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
} else {
setPageError(data.error || t("failedRegenerateKey"));
}
} catch (error) {
console.error("Error regenerating key:", error);
setPageError(t("failedRegenerateKeyRetry"));
} finally {
setIsSubmitting(false);
}
};
const handleOpenPermissions = (key: ApiKey) => {
if (!key || !key.id) return;
setEditingKey(key);
@@ -332,8 +354,11 @@ export default function ApiManagerPageClient() {
allowedConnections: string[],
autoResolve: boolean,
isActive: boolean,
isBanned: boolean,
expiresAt: string | null,
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null
scopes: string[]
) => {
if (!editingKey || !editingKey.id) return;
@@ -378,8 +403,11 @@ export default function ApiManagerPageClient() {
noLog,
autoResolve,
isActive,
isBanned,
expiresAt,
maxSessions: normalizedMaxSessions,
accessSchedule,
rateLimits,
scopes,
}),
});
@@ -707,6 +735,18 @@ export default function ApiManagerPageClient() {
{t("scheduleActive")}
</span>
)}
{key.isBanned && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-600/10 text-red-700 dark:text-red-400 text-[11px] font-bold animate-pulse">
<span className="material-symbols-outlined text-[12px]">gavel</span>
BANNED
</span>
)}
{key.expiresAt && new Date(key.expiresAt).getTime() < Date.now() && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-500/10 text-gray-600 dark:text-gray-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">event_busy</span>
EXPIRED
</span>
)}
</div>
</div>
<div className="col-span-2 flex flex-col justify-center">
@@ -726,6 +766,13 @@ export default function ApiManagerPageClient() {
{new Date(key.createdAt).toLocaleDateString()}
</div>
<div className="col-span-2 flex items-center justify-end gap-1">
<button
onClick={() => handleRegenerateKey(key.id)}
className="p-2 hover:bg-amber-500/10 rounded text-text-muted hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-all"
title={t("regenerateKey")}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
<button
onClick={() => handleOpenPermissions(key)}
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
@@ -920,8 +967,11 @@ const PermissionsModal = memo(function PermissionsModal({
connections: string[],
autoResolve: boolean,
isActive: boolean,
isBanned: boolean,
expiresAt: string | null,
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null
scopes: string[]
) => void;
}) {
@@ -939,6 +989,8 @@ const PermissionsModal = memo(function PermissionsModal({
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
const [keyIsBanned, setKeyIsBanned] = useState(apiKey?.isBanned === true);
const [expiresAt, setExpiresAt] = useState(apiKey?.expiresAt ?? "");
const [manageEnabled, setManageEnabled] = useState(
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes("manage")
);
@@ -954,6 +1006,9 @@ const PermissionsModal = memo(function PermissionsModal({
const [scheduleTz, setScheduleTz] = useState(
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
);
const [rateLimits, setRateLimits] = useState<Array<{ limit: number; window: number }>>(
Array.isArray(apiKey?.rateLimits) ? apiKey.rateLimits : []
);
const [nameError, setNameError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
@@ -1082,8 +1137,11 @@ const PermissionsModal = memo(function PermissionsModal({
allowAllConnections ? [] : selectedConnections,
autoResolveEnabled,
keyIsActive,
keyIsBanned,
expiresAt || null,
maxSessions,
schedule,
rateLimits.length > 0 ? rateLimits : null
manageEnabled ? ["manage"] : []
);
}, [
@@ -1096,6 +1154,8 @@ const PermissionsModal = memo(function PermissionsModal({
selectedConnections,
autoResolveEnabled,
keyIsActive,
keyIsBanned,
expiresAt,
maxSessions,
manageEnabled,
scheduleEnabled,
@@ -1103,6 +1163,7 @@ const PermissionsModal = memo(function PermissionsModal({
scheduleUntil,
scheduleDays,
scheduleTz,
rateLimits,
t,
]);
@@ -1240,6 +1301,72 @@ const PermissionsModal = memo(function PermissionsModal({
</div>
</div>
{/* Custom Rate Limits */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Custom Rate Limits</p>
<p className="text-xs text-text-muted">
Override global default limits. Leave empty to use defaults.
</p>
</div>
<button
type="button"
onClick={() => setRateLimits((prev) => [...prev, { limit: 100, window: 60 }])}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold bg-primary/10 text-primary hover:bg-primary/20 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add Limit
</button>
</div>
{rateLimits.length > 0 && (
<div className="flex flex-col gap-2 pt-2">
{rateLimits.map((rl, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
type="number"
min={1}
value={String(rl.limit)}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
setRateLimits(prev => {
const next = [...prev];
next[index].limit = val;
return next;
});
}}
placeholder="Requests"
/>
<span className="text-sm text-text-muted shrink-0">req /</span>
<Input
type="number"
min={1}
value={String(rl.window)}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
setRateLimits(prev => {
const next = [...prev];
next[index].window = val;
return next;
});
}}
placeholder="Seconds"
/>
<span className="text-sm text-text-muted shrink-0">sec</span>
<button
type="button"
onClick={() => setRateLimits(prev => prev.filter((_, i) => i !== index))}
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
title="Remove limit"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
{/* Access Schedule */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
@@ -1389,6 +1516,12 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
{/* Ban Toggle (SECURITY) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
<div className="flex flex-col gap-1">
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
<p className="text-xs text-red-600 dark:text-red-300">
Immediately revoke all access. Used for suspected abuse or compromised keys.
{/* Management API Access Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
@@ -1401,6 +1534,36 @@ const PermissionsModal = memo(function PermissionsModal({
<button
type="button"
role="switch"
aria-checked={keyIsBanned}
onClick={() => setKeyIsBanned((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
keyIsBanned
? "bg-red-600 text-white shadow-lg shadow-red-500/20"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">gavel</span>
{keyIsBanned ? "BANNED" : "UNBANNED"}
</button>
</div>
{/* Expiration Date */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Expiration Date</p>
<p className="text-xs text-text-muted">Key will automatically stop working after this date.</p>
</div>
<input
type="datetime-local"
value={expiresAt ? expiresAt.slice(0, 16) : ""}
onChange={(e) => {
const val = e.target.value;
setExpiresAt(val ? new Date(val).toISOString() : "");
}}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
aria-checked={manageEnabled}
onClick={() => setManageEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { regenerateApiKey } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import * as log from "@/sse/utils/logger";
/**
* POST /api/keys/[id]/regenerate
*
* Regenerates the API key value for a given ID.
* The old key is immediately invalidated.
*/
export async function POST(request, { params }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing key ID" }, { status: 400 });
}
const result = await regenerateApiKey(id);
if (!result) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
return NextResponse.json({
message: "API key regenerated successfully",
key: result.key,
id: result.id,
});
} catch (error) {
log.error("keys", "Error regenerating key", error);
return NextResponse.json({ error: "Failed to regenerate key" }, { status: 500 });
}
}

View File

@@ -70,8 +70,11 @@ export async function PATCH(request, { params }) {
noLog,
autoResolve,
isActive,
isBanned,
expiresAt,
maxSessions,
accessSchedule,
rateLimits,
scopes,
} = validation.data;
@@ -82,8 +85,11 @@ export async function PATCH(request, { params }) {
if (noLog !== undefined) payload.noLog = noLog;
if (autoResolve !== undefined) payload.autoResolve = autoResolve;
if (isActive !== undefined) payload.isActive = isActive;
if (isBanned !== undefined) payload.isBanned = isBanned;
if (expiresAt !== undefined) payload.expiresAt = expiresAt;
if (maxSessions !== undefined) payload.maxSessions = maxSessions;
if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule;
if (rateLimits !== undefined) payload.rateLimits = rateLimits;
if (scopes !== undefined) payload.scopes = scopes;
const updated = await updateApiKeyPermissions(id, payload);
@@ -102,8 +108,11 @@ export async function PATCH(request, { params }) {
...(noLog !== undefined && { noLog }),
...(autoResolve !== undefined && { autoResolve }),
...(isActive !== undefined && { isActive }),
...(isBanned !== undefined && { isBanned }),
...(expiresAt !== undefined && { expiresAt }),
...(maxSessions !== undefined && { maxSessions }),
...(accessSchedule !== undefined && { accessSchedule }),
...(rateLimits !== undefined && { rateLimits }),
...(scopes !== undefined && { scopes }),
});
} catch (error) {

View File

@@ -1033,6 +1033,10 @@
"lastUsedOn": "Last: {date}",
"editPermissions": "Edit permissions",
"deleteKey": "Delete key",
"regenerateKey": "Regenerate key",
"regenerateConfirm": "Are you sure you want to regenerate this API key? The old key will be immediately invalidated.",
"failedRegenerateKey": "Failed to regenerate API key",
"failedRegenerateKeyRetry": "Failed to regenerate API key. Please try again.",
"model": "{count} model",
"models": "{count} models",
"permissionsTitle": "Permissions: {name}",

View File

@@ -931,6 +931,10 @@
"lastUsedOn": "Terakhir: {date}",
"editPermissions": "Edit izin",
"deleteKey": "Hapus kunci",
"regenerateKey": "Hasilkan ulang kunci",
"regenerateConfirm": "Apakah Anda yakin ingin menghasilkan ulang kunci API ini? Kunci lama akan segera tidak berlaku.",
"failedRegenerateKey": "Gagal menghasilkan ulang kunci API",
"failedRegenerateKeyRetry": "Gagal menghasilkan ulang kunci API. Silakan coba lagi.",
"model": "{count} modelnya",
"models": "{count} model",
"permissionsTitle": "Izin: {name}",

View File

@@ -2,6 +2,7 @@
* db/apiKeys.js — API key management.
*/
import { createHash } from "crypto";
import { v4 as uuidv4 } from "uuid";
import { getDbInstance, rowToCamel } from "./core";
import { backupDbFile } from "./backup";
@@ -20,6 +21,11 @@ interface CacheEntry<TValue> {
value: TValue;
}
export interface RateLimitRule {
limit: number;
window: number;
}
export interface AccessSchedule {
enabled: boolean;
from: string;
@@ -40,6 +46,7 @@ interface ApiKeyMetadata {
accessSchedule: AccessSchedule | null;
maxRequestsPerDay: number | null;
maxRequestsPerMinute: number | null;
rateLimits: RateLimitRule[] | null;
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
maxSessions: number;
// Phase 3 lifecycle/policy fields
@@ -47,6 +54,8 @@ interface ApiKeyMetadata {
expiresAt: string | null;
ipAllowlist: string[];
scopes: string[];
isBanned: boolean;
keyHash: string | null;
}
interface ApiKeyRow extends JsonRecord {
@@ -67,6 +76,8 @@ interface ApiKeyRow extends JsonRecord {
isActive?: unknown;
access_schedule?: unknown;
accessSchedule?: unknown;
rate_limits?: unknown;
rateLimits?: unknown;
}
interface StatementLike<TRow = unknown> {
@@ -97,6 +108,7 @@ interface ApiKeyView extends JsonRecord {
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
rateLimits: RateLimitRule[] | null;
}
// LRU cache for API key validation (valid keys only)
@@ -126,6 +138,9 @@ const API_KEY_COLUMN_FALLBACKS = [
{ name: "key_prefix", definition: "key_prefix TEXT" },
{ name: "ip_allowlist", definition: "ip_allowlist TEXT" },
{ name: "scopes", definition: "scopes TEXT" },
{ name: "rate_limits", definition: "rate_limits TEXT" },
{ name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" },
{ name: "key_hash", definition: "key_hash TEXT" },
] as const;
// Cache for model permission checks
@@ -248,12 +263,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
_stmtGetAllKeys = db.prepare<ApiKeyRow>("SELECT * FROM api_keys ORDER BY created_at");
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>(
"SELECT id, expires_at, revoked_at, is_active FROM api_keys WHERE key = ?"
"SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
@@ -292,6 +308,8 @@ export async function getApiKeys() {
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -311,6 +329,8 @@ export async function getApiKeyById(id: string) {
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -378,6 +398,23 @@ function parseAccessSchedule(value: unknown): AccessSchedule | null {
}
}
function parseRateLimits(value: unknown): RateLimitRule[] | null {
if (!value || typeof value !== "string" || value.trim() === "") return null;
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(rule: any) =>
typeof rule === "object" &&
rule !== null &&
typeof rule.limit === "number" &&
typeof rule.window === "number"
) as RateLimitRule[];
} catch {
return null;
}
}
/**
* Helper function to safely parse allowed_connections JSON
*/
@@ -413,6 +450,16 @@ function parseNullableTimestamp(value: unknown): string | null {
return trimmed === "" ? null : trimmed;
}
function parseIsBanned(value: unknown): boolean {
return value === 1 || value === "1" || value === true;
}
async function hashKey(key: string): Promise<string> {
if (!key || typeof key !== "string") return "";
return createHash("sha256").update(key).digest("hex");
}
export async function createApiKey(name: string, machineId: string) {
export async function createApiKey(name: string, machineId: string, scopes: string[] = []) {
if (!machineId) {
throw new Error("machineId is required");
@@ -446,6 +493,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
0,
apiKey.createdAt,
apiKey.key.slice(0, 12),
await hashKey(apiKey.key)
JSON.stringify(scopes)
);
setNoLog(apiKey.id, false);
@@ -454,6 +502,47 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
return apiKey;
}
export async function regenerateApiKey(id: string) {
const db = getDbInstance() as ApiKeysDbLike;
const stmt = getPreparedStatements(db);
const row = stmt.getKeyById.get(id) as ApiKeyRow | undefined;
if (!row) return null;
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
const machineId = (row.machine_id || row.machineId || "0000000000000000") as string;
const { key: newKey } = generateApiKeyWithMachine(machineId);
const newHash = await hashKey(newKey);
const newPrefix = newKey.slice(0, 12);
// Update in DB
const updateStmt = db.prepare(
"UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?"
);
updateStmt.run(newKey, newHash, newPrefix, id);
// Invalidate all caches
clearApiKeyCaches();
// Redis invalidation
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
if (typeof row.key_hash === "string") await redis.del(`auth:api_key:${row.key_hash}`);
await redis.del(`auth:api_key:${newHash}`);
} catch (err) {
// Fail silent
}
const { logAuditEvent } = await import("@/lib/compliance");
logAuditEvent({
action: "apiKey.regenerate",
target: id,
details: { name: String(row.name || "") },
});
return { id, key: newKey };
}
export async function updateApiKeyPermissions(
id: string,
update:
@@ -468,6 +557,9 @@ export async function updateApiKeyPermissions(
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
rateLimits?: RateLimitRule[] | null;
isBanned?: boolean;
expiresAt?: string | null;
// T08: max concurrent sessions for this key (0 = unlimited)
maxSessions?: number | null;
scopes?: string[] | null;
@@ -489,6 +581,11 @@ export async function updateApiKeyPermissions(
accessSchedule: update.accessSchedule,
maxRequestsPerDay: update.maxRequestsPerDay,
maxRequestsPerMinute: update.maxRequestsPerMinute,
rateLimits: update.rateLimits,
isBanned: update.isBanned,
expiresAt: update.expiresAt,
maxSessions: (update as { maxSessions?: number | null; expiresAt?: string | null })
.maxSessions,
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
scopes: (update as { scopes?: string[] | null }).scopes,
};
@@ -503,6 +600,10 @@ export async function updateApiKeyPermissions(
normalized.accessSchedule === undefined &&
normalized.maxRequestsPerDay === undefined &&
normalized.maxRequestsPerMinute === undefined &&
normalized.rateLimits === undefined &&
normalized.isBanned === undefined &&
normalized.expiresAt === undefined &&
(normalized as Record<string, unknown>).maxSessions === undefined
(normalized as Record<string, unknown>).maxSessions === undefined &&
(normalized as Record<string, unknown>).scopes === undefined
) {
@@ -521,7 +622,10 @@ export async function updateApiKeyPermissions(
accessSchedule?: string | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
rateLimits?: string | null;
isBanned?: number;
maxSessions?: number;
expiresAt?: string | null;
scopes?: string;
} = { id };
@@ -573,6 +677,22 @@ export async function updateApiKeyPermissions(
params.maxRequestsPerMinute = normalized.maxRequestsPerMinute;
}
if (normalized.rateLimits !== undefined) {
updates.push("rate_limits = @rateLimits");
params.rateLimits =
normalized.rateLimits !== null ? JSON.stringify(normalized.rateLimits) : null;
}
if (normalized.isBanned !== undefined) {
updates.push("is_banned = @isBanned");
params.isBanned = normalized.isBanned ? 1 : 0;
}
if (normalized.expiresAt !== undefined) {
updates.push("expires_at = @expiresAt");
params.expiresAt = normalized.expiresAt;
}
const maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
if (maxSessionsUpdate !== undefined) {
updates.push("max_sessions = @maxSessions");
@@ -589,6 +709,22 @@ export async function updateApiKeyPermissions(
if (result.changes === 0) return false;
const { logAuditEvent } = await import("@/lib/compliance");
if (normalized.isBanned !== undefined) {
logAuditEvent({
action: normalized.isBanned ? "apiKey.ban" : "apiKey.unban",
target: id,
});
}
if (normalized.isActive !== undefined) {
logAuditEvent({
action: normalized.isActive ? "apiKey.activate" : "apiKey.deactivate",
target: id,
});
}
if (normalized.noLog !== undefined) {
setNoLog(id, normalized.noLog);
}
@@ -596,6 +732,18 @@ export async function updateApiKeyPermissions(
// Invalidate caches since permissions changed
invalidateCaches();
// Also invalidate Redis if key_hash is available
try {
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as { key_hash: string | null } | undefined;
if (row?.key_hash) {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
await redis.del(`auth:api_key:${row.key_hash}`);
}
} catch (err) {
// Fail silent
}
backupDbFile("pre-write");
return true;
}
@@ -678,18 +826,48 @@ export async function validateApiKey(key: string | null | undefined) {
if (isConfiguredEnvApiKey(key)) return true;
const now = Date.now();
const hashedKey = await hashKey(key);
const cacheKey = hashedKey;
const cached = _keyValidationCache.get(key);
const cached = _keyValidationCache.get(cacheKey);
if (cached && now - cached.timestamp < CACHE_TTL) {
return cached.valid;
}
// Try Redis cache for multi-instance consistency
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
const redisKey = `auth:api_key:${hashedKey}`;
const redisData = await redis.get(redisKey);
if (redisData) {
const data = JSON.parse(redisData);
const isBanned = !!data.isBanned;
const isActive = !!data.isActive;
const revokedAt = data.revokedAt;
const expiresAt = data.expiresAt;
if (isBanned || !isActive) return false;
if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false;
if (typeof expiresAt === "string" && expiresAt.trim() !== "") {
const expiresMs = Date.parse(expiresAt);
if (Number.isFinite(expiresMs) && expiresMs <= now) return false;
}
return true;
}
} catch (err) {
// Fail silent for Redis lookup
}
const db = getDbInstance() as ApiKeysDbLike;
const stmt = getPreparedStatements(db);
const row = stmt.validateKey.get(key) as JsonRecord | undefined;
const row = stmt.validateKey.get(key, hashedKey) as JsonRecord | undefined;
if (!row) return false;
const isBanned = parseIsBanned(row.is_banned ?? row.isBanned);
if (isBanned) return false;
const isActive = parseIsActive(row.is_active ?? row.isActive);
if (!isActive) return false;
@@ -703,7 +881,29 @@ export async function validateApiKey(key: string | null | undefined) {
}
evictIfNeeded(_keyValidationCache);
_keyValidationCache.set(key, { valid: true, timestamp: now });
_keyValidationCache.set(cacheKey, { valid: true, timestamp: now });
// Update Redis cache for fast validation
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
const redisKey = `auth:api_key:${hashedKey}`;
await redis.set(
redisKey,
JSON.stringify({
id: row.id,
isBanned: parseIsBanned(row.is_banned),
isActive: parseIsActive(row.is_active),
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
}),
"EX",
3600 // 1 hour cache
);
} catch (err) {
// Fail silent for Redis cache update
}
markApiKeyUsed(db, row.id, now);
return true;
@@ -731,25 +931,30 @@ export async function getApiKeyMetadata(
autoResolve: true,
isActive: true,
accessSchedule: null,
rateLimits: null,
maxRequestsPerDay: null,
maxRequestsPerMinute: null,
maxSessions: 0,
revokedAt: null,
expiresAt: null,
ipAllowlist: [],
scopes: [],
isBanned: false,
keyHash: null,
scopes: ["manage"],
};
}
// Check cache first
const cached = _keyMetadataCache.get(key);
const hashedKey = await hashKey(key);
const cached = _keyMetadataCache.get(hashedKey);
if (cached && now - cached.timestamp < CACHE_TTL) {
return cached.value;
}
const db = getDbInstance() as ApiKeysDbLike;
const stmt = getPreparedStatements(db);
const row = stmt.getKeyMetadata.get(key);
const row = stmt.getKeyMetadata.get(key, hashedKey);
if (!row) return null;
@@ -776,6 +981,7 @@ export async function getApiKeyMetadata(
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
isActive: parseIsActive(record.is_active ?? record.isActive),
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
rateLimits: parseRateLimits(record.rate_limits ?? (record as JsonRecord).rateLimits),
maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null,
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
// T08: max concurrent sessions; 0 = unlimited (default & backward-compatible)
@@ -784,6 +990,8 @@ export async function getApiKeyMetadata(
expiresAt: parseNullableTimestamp(record.expires_at ?? (record as JsonRecord).expiresAt),
ipAllowlist: parseStringList(record.ip_allowlist ?? (record as JsonRecord).ipAllowlist),
scopes: parseStringList((record as JsonRecord).scopes),
isBanned: parseIsBanned(record.is_banned ?? (record as JsonRecord).isBanned),
keyHash: (record.key_hash ?? (record as JsonRecord).keyHash) as string | null,
};
if (!metadata.id) {
@@ -794,7 +1002,7 @@ export async function getApiKeyMetadata(
// Cache the result
evictIfNeeded(_keyMetadataCache);
_keyMetadataCache.set(key, { value: metadata, timestamp: now });
_keyMetadataCache.set(hashedKey, { value: metadata, timestamp: now });
return metadata;
}

View File

@@ -88,6 +88,7 @@ function nowHour(): string {
}
function hashKey(raw: string): string {
if (!raw || typeof raw !== "string") return "";
return createHash("sha256").update(raw).digest("hex");
}

View File

@@ -92,6 +92,7 @@ export {
validateApiKey,
getApiKeyMetadata,
updateApiKeyPermissions,
regenerateApiKey,
isModelAllowedForKey,
clearApiKeyCaches,
resetApiKeyState,

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({

View File

@@ -28,6 +28,9 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-607-api-key-sec
const coreDb = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const costRules = await import("../../src/domain/costRules.ts");
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
rateLimiter.setRateLimiterTestMode(true);
async function resetStorage() {
apiKeysDb.resetApiKeyState();
@@ -477,5 +480,5 @@ test("enforceApiKeyPolicy enforces request-per-minute limits and returns success
"openai/gpt-4.1"
);
assert.equal(second.rejection.status, 429);
assert.match(await readErrorMessage(second.rejection), /Per-minute request limit exceeded/);
assert.match(await readErrorMessage(second.rejection), /Request limit exceeded/);
});

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-apikey-regen-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret-regen";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
function reset() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
reset();
});
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("regenerateApiKey creates a new key and invalidates the old one", async () => {
const machineId = "test-machine-regen";
const created = await apiKeysDb.createApiKey("Regen Test", machineId);
const oldKey = created.key;
const oldId = created.id;
assert.ok(oldKey);
assert.equal(await apiKeysDb.validateApiKey(oldKey), true);
// Regenerate
const result = await apiKeysDb.regenerateApiKey(oldId);
assert.ok(result?.key);
const regenerated = result!.key;
assert.notEqual(regenerated, oldKey);
// New key should be valid
assert.equal(await apiKeysDb.validateApiKey(regenerated), true);
// Old key should be invalid
assert.equal(await apiKeysDb.validateApiKey(oldKey), false);
// Name and machineId should persist
const md = await apiKeysDb.getApiKeyMetadata(regenerated);
assert.equal(md?.name, "Regen Test");
assert.ok(regenerated.startsWith(`sk-${machineId}-`));
});
test("regenerateApiKey returns null for non-existent ID", async () => {
const result = await apiKeysDb.regenerateApiKey("00000000-0000-0000-0000-000000000000");
assert.equal(result, null);
});

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const registeredKeysDb = await import("../../src/lib/db/registeredKeys.ts");
test("hashKey in apiKeys handles null/undefined safely", async () => {
// @ts-ignore - testing runtime safety
const resultNull = await apiKeysDb.validateApiKey(null);
assert.equal(resultNull, false);
// @ts-ignore - testing runtime safety
const resultUndefined = await apiKeysDb.validateApiKey(undefined);
assert.equal(resultUndefined, false);
});
test("hashKey in registeredKeys handles null/undefined safely", () => {
// @ts-ignore - testing runtime safety
const resultNull = registeredKeysDb.validateRegisteredKey(null);
assert.equal(resultNull, null);
// @ts-ignore - testing runtime safety
const resultUndefined = registeredKeysDb.validateRegisteredKey(undefined);
assert.equal(resultUndefined, null);
});