mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
88
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
34
scripts/scratch/check_usage.js
Normal file
34
scripts/scratch/check_usage.js
Normal 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();
|
||||
}
|
||||
@@ -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 ${
|
||||
|
||||
36
src/app/api/keys/[id]/regenerate/route.ts
Normal file
36
src/app/api/keys/[id]/regenerate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ export {
|
||||
validateApiKey,
|
||||
getApiKeyMetadata,
|
||||
updateApiKeyPermissions,
|
||||
regenerateApiKey,
|
||||
isModelAllowedForKey,
|
||||
clearApiKeyCaches,
|
||||
resetApiKeyState,
|
||||
|
||||
@@ -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.`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
142
src/shared/utils/rateLimiter.ts
Normal file
142
src/shared/utils/rateLimiter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
62
tests/unit/api-key-regeneration.test.ts
Normal file
62
tests/unit/api-key-regeneration.test.ts
Normal 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);
|
||||
});
|
||||
25
tests/unit/security-hashkey.test.ts
Normal file
25
tests/unit/security-hashkey.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user