diff --git a/CHANGELOG.md b/CHANGELOG.md index 88bcac19ca..aa940b405a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) - **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. - **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix) +- **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra) ### 🔧 Bug Fixes diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 23958514e7..7e450f6dc9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -22,6 +22,7 @@ import { isStripReasoningRequested, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; +import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts"; import { getCombosCached } from "./chatCore/comboContextCache.ts"; export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts"; import { @@ -455,6 +456,15 @@ export async function handleChatCore({ if (pluginGate.body) { body = pluginGate.body; } + // Per-API-key device/connection tracking (port of upstream 9router#931, + // thanks @mugnimaestra). In-memory only, never blocks the request path. + if (apiKeyInfo?.id) { + trackDevice( + apiKeyInfo.id, + extractIpFromHeaders(clientRawRequest?.headers ?? null), + userAgent ?? null + ); + } const agentGoalPolicy = resolveAgentGoalPolicy(body, clientRawRequest?.headers ?? null); if (agentGoalPolicy.detected) { log?.debug?.( diff --git a/open-sse/services/deviceTracker.ts b/open-sse/services/deviceTracker.ts new file mode 100644 index 0000000000..84d6985f57 --- /dev/null +++ b/open-sse/services/deviceTracker.ts @@ -0,0 +1,307 @@ +/** + * Per-API-Key Device Tracker + * + * Tracks unique client "devices" (IP + User-Agent fingerprint) that have used + * a given API key, so operators can see how many distinct connections are + * active behind a key — independent of `maxSessions` (which caps concurrent + * sticky-routing sessions, not device identity; see `sessionManager.ts`). + * + * In-memory only, module-scoped Map (same pattern as `sessionManager.ts` — + * no `global.*` singleton). Records never store the raw IP: it is masked + * before being written, so even a memory dump or the `/api/keys/[id]/devices` + * endpoint can't leak a full client IP. + * + * Ported from upstream 9router#931 (thanks @mugnimaestra) — original stored + * a global singleton keyed by the raw API key string; this port keys by + * `apiKeyInfo.id` (OmniRoute never threads the raw key value down to + * `chatCore`) and follows the module-Map + `unref()` cleanup-timer pattern + * used across `open-sse/services/`. + */ + +import { createHash } from "node:crypto"; + +const DEFAULT_TTL_MS = 30 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 60 * 1000; +const DEFAULT_MAX_DEVICES_PER_API_KEY = 1000; +const DEFAULT_MAX_TOTAL_DEVICES = 10000; +const MAX_STORED_USER_AGENT_LENGTH = 256; + +const TTL_ENV_NAME = "DEVICE_TRACKER_TTL_MS"; +const MAX_PER_KEY_ENV_NAME = "DEVICE_TRACKER_MAX_DEVICES_PER_KEY"; +const MAX_TOTAL_ENV_NAME = "DEVICE_TRACKER_MAX_TOTAL_DEVICES"; + +interface DeviceRecord { + fingerprint: string; + /** Already masked — never the raw client IP. */ + ip: string; + /** Truncated to MAX_STORED_USER_AGENT_LENGTH. */ + userAgent: string; + lastSeen: number; +} + +export interface DeviceDetail { + /** Truncated fingerprint (first 12 hex chars) — never the full hash. */ + fingerprint: string; + ip: string; + userAgent: string; + lastSeen: number; +} + +function parseTtlMs(): number { + const rawValue = process.env[TTL_ENV_NAME]; + if (!rawValue) return DEFAULT_TTL_MS; + const parsedValue = Number(rawValue); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) return DEFAULT_TTL_MS; + return parsedValue; +} + +function parsePositiveIntegerEnv(envName: string, defaultValue: number): number { + const rawValue = process.env[envName]; + if (!rawValue) return defaultValue; + const parsedValue = Number(rawValue); + if (!Number.isInteger(parsedValue) || parsedValue <= 0) return defaultValue; + return parsedValue; +} + +let ttlMs = parseTtlMs(); +let maxDevicesPerApiKey = parsePositiveIntegerEnv( + MAX_PER_KEY_ENV_NAME, + DEFAULT_MAX_DEVICES_PER_API_KEY +); +let maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES); + +// Module-scoped in-memory store — mirrors the `sessionManager.ts` pattern. +// key: apiKeyId → Map +const devicesByApiKey = new Map>(); + +let cleanupTimer: ReturnType | null = null; + +/** + * Mask an IP address so the stored/reported value never reveals the full + * client address. IPv4 keeps the first two octets; IPv6 keeps the first + * three groups. + */ +export function maskIp(ip: string | null | undefined): string { + if (!ip || ip === "unknown") return "unknown"; + + const ipv4Parts = ip.split("."); + if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d{1,3}$/.test(part))) { + return `${ipv4Parts[0]}.${ipv4Parts[1]}.x.x`; + } + + if (ip.includes(":")) { + const visibleGroups = ip.split(":").filter(Boolean).slice(0, 3).join(":"); + return visibleGroups ? `${visibleGroups}:...` : "unknown"; + } + + return "masked"; +} + +function truncateUserAgent(userAgent: string): string { + if (userAgent.length <= MAX_STORED_USER_AGENT_LENGTH) return userAgent; + return `${userAgent.slice(0, MAX_STORED_USER_AGENT_LENGTH)}...`; +} + +function createFingerprint(ip: string, userAgent: string): string { + return createHash("sha256").update(`${ip}|${userAgent}`).digest("hex"); +} + +function getTotalDeviceCount(): number { + let count = 0; + for (const devices of devicesByApiKey.values()) count += devices.size; + return count; +} + +function deleteDevice(apiKeyId: string, fingerprint: string): boolean { + const devices = devicesByApiKey.get(apiKeyId); + if (!devices) return false; + const deleted = devices.delete(fingerprint); + if (devices.size === 0) devicesByApiKey.delete(apiKeyId); + return deleted; +} + +function findOldestDevice( + apiKeyId: string | null +): { apiKeyId: string; fingerprint: string; lastSeen: number } | null { + let oldest: { apiKeyId: string; fingerprint: string; lastSeen: number } | null = null; + const entries = apiKeyId ? [[apiKeyId, devicesByApiKey.get(apiKeyId)] as const] : devicesByApiKey.entries(); + + for (const [entryApiKeyId, devices] of entries) { + if (!devices) continue; + for (const [fingerprint, record] of devices.entries()) { + if (!oldest || record.lastSeen < oldest.lastSeen) { + oldest = { apiKeyId: entryApiKeyId, fingerprint, lastSeen: record.lastSeen }; + } + } + } + + return oldest; +} + +function evictOldestDevice(apiKeyId: string | null = null): boolean { + const oldest = findOldestDevice(apiKeyId); + if (!oldest) return false; + return deleteDevice(oldest.apiKeyId, oldest.fingerprint); +} + +function enforceDeviceLimits(apiKeyId: string, devices: Map): void { + while (devices.size >= maxDevicesPerApiKey) { + if (!evictOldestDevice(apiKeyId)) break; + } + while (getTotalDeviceCount() >= maxTotalDevices) { + if (!evictOldestDevice()) break; + } +} + +/** + * Remove expired device records. Exported for tests; the cleanup timer + * calls this on an interval in production. + */ +export function expireDevices(now: number = Date.now()): number { + let expiredCount = 0; + + for (const [apiKeyId, devices] of devicesByApiKey.entries()) { + for (const [fingerprint, record] of devices.entries()) { + if (now - record.lastSeen > ttlMs) { + devices.delete(fingerprint); + expiredCount += 1; + } + } + if (devices.size === 0) devicesByApiKey.delete(apiKeyId); + } + + return expiredCount; +} + +function ensureCleanupTimer(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + expireDevices(); + }, CLEANUP_INTERVAL_MS); + cleanupTimer.unref?.(); +} + +ensureCleanupTimer(); + +/** + * Extract the client IP from a header source. Mirrors the priority order + * already used across `open-sse/` (`cf-connecting-ip` → `x-real-ip` → + * `x-forwarded-for`, first hop only). Returns "unknown" when absent. + */ +export function extractIpFromHeaders( + headers: Record | Headers | null | undefined +): string { + if (!headers) return "unknown"; + + const getHeader = (name: string): string | null => { + if (headers instanceof Headers) return headers.get(name); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name && typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; + }; + + const edgeIp = + getHeader("cf-connecting-ip") || getHeader("x-real-ip") || getHeader("fastly-client-ip"); + if (edgeIp) return edgeIp; + + const forwardedFor = getHeader("x-forwarded-for"); + if (forwardedFor) { + const firstIp = forwardedFor.split(",")[0]?.trim(); + if (firstIp) return firstIp; + } + + return "unknown"; +} + +/** + * Track a device (IP + User-Agent fingerprint) for an API key. Idempotent — + * calling it again for the same key + fingerprint just refreshes `lastSeen`. + * No-ops (returns null) when `apiKeyId` is missing, so callers can call it + * unconditionally after resolving `apiKeyInfo`. + */ +export function trackDevice( + apiKeyId: string | null | undefined, + ip: string | null | undefined, + userAgent: string | null | undefined +): string | null { + if (!apiKeyId || typeof apiKeyId !== "string") return null; + + const now = Date.now(); + expireDevices(now); + + const resolvedIp = ip && ip.trim() ? ip.trim() : "unknown"; + const resolvedUserAgent = userAgent && userAgent.trim() ? userAgent.trim() : "unknown"; + const fingerprint = createFingerprint(resolvedIp, resolvedUserAgent); + + let devices = devicesByApiKey.get(apiKeyId); + if (!devices) { + devices = new Map(); + devicesByApiKey.set(apiKeyId, devices); + } + + const existingRecord = devices.get(fingerprint); + if (existingRecord) { + existingRecord.lastSeen = now; + } else { + enforceDeviceLimits(apiKeyId, devices); + if (!devicesByApiKey.has(apiKeyId)) devicesByApiKey.set(apiKeyId, devices); + devices.set(fingerprint, { + fingerprint, + ip: maskIp(resolvedIp), + userAgent: truncateUserAgent(resolvedUserAgent), + lastSeen: now, + }); + } + + return fingerprint; +} + +/** Number of distinct devices currently tracked for an API key. */ +export function getDeviceCount(apiKeyId: string | null | undefined): number { + expireDevices(); + if (!apiKeyId || typeof apiKeyId !== "string") return 0; + return devicesByApiKey.get(apiKeyId)?.size || 0; +} + +/** Device detail rows for an API key — masked IP, truncated fingerprint. */ +export function getDeviceDetails(apiKeyId: string | null | undefined): DeviceDetail[] { + expireDevices(); + if (!apiKeyId || typeof apiKeyId !== "string") return []; + + const devices = devicesByApiKey.get(apiKeyId); + if (!devices) return []; + + return Array.from(devices.values()).map((record) => ({ + fingerprint: record.fingerprint.slice(0, 12), + ip: record.ip, + userAgent: record.userAgent, + lastSeen: record.lastSeen, + })); +} + +/** Device counts for every tracked API key. */ +export function getAllDeviceCounts(): Record { + expireDevices(); + const counts: Record = {}; + for (const [apiKeyId, devices] of devicesByApiKey.entries()) { + counts[apiKeyId] = devices.size; + } + return counts; +} + +/** + * Test-only reset — mirrors `sessionManager.ts::clearSessions()`. Also lets + * tests override the TTL/limit env vars deterministically. + */ +export function clearDeviceTracker(): void { + devicesByApiKey.clear(); + ttlMs = parseTtlMs(); + maxDevicesPerApiKey = parsePositiveIntegerEnv( + MAX_PER_KEY_ENV_NAME, + DEFAULT_MAX_DEVICES_PER_API_KEY + ); + maxTotalDevices = parsePositiveIntegerEnv(MAX_TOTAL_ENV_NAME, DEFAULT_MAX_TOTAL_DEVICES); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 362196321d..3a54c5a74f 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -222,6 +222,7 @@ export default function ApiManagerPageClient() { const [isSubmitting, setIsSubmitting] = useState(false); const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); + const [deviceCounts, setDeviceCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); // Per-row API key visibility toggle (eye / eye-off). Keys default to masked. // Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal. @@ -367,6 +368,7 @@ export default function ApiManagerPageClient() { // Fetch usage stats after keys are loaded fetchUsageStats(data.keys || []); fetchSessionCounts(data.keys || []); + fetchDeviceCounts(data.keys || []); } } catch (error) { console.log("Error fetching keys:", error); @@ -447,6 +449,35 @@ export default function ApiManagerPageClient() { } }; + // Per-key device/connection counts (port of upstream 9router#931, thanks + // @mugnimaestra). One lightweight GET per key against + // /api/keys/[id]/devices — device counts are in-memory + TTL-evicted, so + // this is a much smaller payload than session data. + const fetchDeviceCounts = async (apiKeys: ApiKey[]) => { + if (apiKeys.length === 0) { + setDeviceCounts({}); + return; + } + try { + const results = await Promise.all( + apiKeys.map(async (key) => { + try { + const res = await fetch(`/api/keys/${encodeURIComponent(key.id)}/devices`); + if (!res.ok) return [key.id, 0] as const; + const data = await res.json(); + const count = typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; + return [key.id, count] as const; + } catch { + return [key.id, 0] as const; + } + }) + ); + setDeviceCounts(Object.fromEntries(results)); + } catch (error) { + console.log("Error fetching device counts:", error); + } + }; + const clearPageError = useCallback(() => setPageError(null), []); const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]); @@ -963,6 +994,7 @@ export default function ApiManagerPageClient() { const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; const activeSessions = sessionCounts[key.id] || 0; + const deviceCount = deviceCounts[key.id] || 0; const hasSchedule = key.accessSchedule?.enabled === true; const keyIsQuota = isQuotaKey(key); const groups = quotaGroupsForKey(key); @@ -1124,6 +1156,15 @@ export default function ApiManagerPageClient() { Sessions: {activeSessions}/{maxSessions} )} + {deviceCount > 0 && ( + + devices + {t("devicesCount", { count: deviceCount })} + + )} {hasThrottle && ( speed+ diff --git a/src/app/api/keys/[id]/devices/route.ts b/src/app/api/keys/[id]/devices/route.ts new file mode 100644 index 0000000000..584ea52c0a --- /dev/null +++ b/src/app/api/keys/[id]/devices/route.ts @@ -0,0 +1,46 @@ +/** + * Per-API-Key Device List — Read Route + * + * Lists the distinct devices (IP + User-Agent fingerprints) tracked for an + * API key by `open-sse/services/deviceTracker.ts` (in-memory, TTL-evicted). + * IPs are already masked by the tracker before storage — this route never + * has access to the raw client IP or the full SHA-256 fingerprint. + * + * Ported from upstream 9router#931 (thanks @mugnimaestra) — the original + * exposed a flat `GET /api/keys/devices` listing every key; this route + * follows the OmniRoute `[id]` sub-resource convention (see + * `src/app/api/keys/[id]/usage-limits/route.ts`) and requires management + * auth like every other `/api/keys/[id]/*` route. + * + * @route /api/keys/[id]/devices + */ + +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getApiKeyById } from "@/lib/db/apiKeys"; +import { getDeviceCount, getDeviceDetails } from "@omniroute/open-sse/services/deviceTracker.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import * as log from "@/sse/utils/logger"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const key = await getApiKeyById(id); + if (!key || typeof key.id !== "string") { + return NextResponse.json(buildErrorBody(404, "Key not found"), { status: 404 }); + } + + return NextResponse.json({ + keyId: key.id, + name: typeof key.name === "string" ? key.name : "", + count: getDeviceCount(key.id), + devices: getDeviceDetails(key.id), + }); + } catch (error) { + log.error("keys", "Error fetching API key devices", error); + return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(error)), { status: 500 }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index dc07497c2b..1c033f4a48 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1763,6 +1763,8 @@ "copyMaskedKey": "Copy masked key", "keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key", "modelsCount": "{count, plural, one {# model} other {# models}}", + "devicesCount": "{count, plural, one {# device} other {# devices}}", + "devicesTooltip": "{count, plural, one {# distinct IP/User-Agent device seen with this key (last 30 min)} other {# distinct IP/User-Agent devices seen with this key (last 30 min)}}", "lastUsedOn": "Last: {date}", "viewCostsFor": "View costs for {name}", "editPermissions": "Edit permissions", diff --git a/tests/unit/device-tracker.test.ts b/tests/unit/device-tracker.test.ts new file mode 100644 index 0000000000..bbe7682a9a --- /dev/null +++ b/tests/unit/device-tracker.test.ts @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + trackDevice, + getDeviceCount, + getDeviceDetails, + getAllDeviceCounts, + expireDevices, + extractIpFromHeaders, + maskIp, + clearDeviceTracker, +} = await import("../../open-sse/services/deviceTracker.ts"); + +test.beforeEach(() => { + delete process.env.DEVICE_TRACKER_TTL_MS; + delete process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY; + delete process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES; + clearDeviceTracker(); +}); + +// ─── Fingerprint dedup ────────────────────────────────────────────────────── + +test("trackDevice: same IP + UA for a key counts as one device", async () => { + await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent"); + await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent"); + await trackDevice("key-1", "203.0.113.5", "Mozilla/5.0 test-agent"); + + assert.equal(getDeviceCount("key-1"), 1); +}); + +test("trackDevice: different User-Agent for same key/IP counts as a new device", async () => { + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + await trackDevice("key-1", "203.0.113.5", "python-requests/2.31"); + + assert.equal(getDeviceCount("key-1"), 2); +}); + +test("trackDevice: different IP for same key/UA counts as a new device", async () => { + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + await trackDevice("key-1", "198.51.100.9", "curl/8.0"); + + assert.equal(getDeviceCount("key-1"), 2); +}); + +test("trackDevice: repeated tracking refreshes lastSeen instead of duplicating", async () => { + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + const [before] = getDeviceDetails("key-1"); + await new Promise((resolve) => setTimeout(resolve, 5)); + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + const [after] = getDeviceDetails("key-1"); + + assert.equal(getDeviceCount("key-1"), 1); + assert.ok(after.lastSeen >= before.lastSeen); +}); + +test("trackDevice: no-ops and returns null when apiKeyId is missing", async () => { + const fingerprint = await trackDevice(null, "203.0.113.5", "curl/8.0"); + assert.equal(fingerprint, null); + assert.equal(getDeviceCount(null), 0); +}); + +// ─── Per-key isolation ────────────────────────────────────────────────────── + +test("trackDevice: devices are scoped per API key, not global", async () => { + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + await trackDevice("key-2", "203.0.113.5", "curl/8.0"); // same fingerprint, different key + + assert.equal(getDeviceCount("key-1"), 1); + assert.equal(getDeviceCount("key-2"), 1); + + const allCounts = getAllDeviceCounts(); + assert.equal(allCounts["key-1"], 1); + assert.equal(allCounts["key-2"], 1); +}); + +// ─── TTL expiry ───────────────────────────────────────────────────────────── + +test("expireDevices: evicts devices whose lastSeen exceeds the TTL window", async () => { + process.env.DEVICE_TRACKER_TTL_MS = "1000"; + clearDeviceTracker(); + + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + assert.equal(getDeviceCount("key-1"), 1); + + const farFuture = Date.now() + 5000; + const expiredCount = expireDevices(farFuture); + + assert.equal(expiredCount, 1); + assert.equal(getDeviceCount("key-1"), 0); +}); + +test("expireDevices: keeps devices seen within the TTL window", async () => { + process.env.DEVICE_TRACKER_TTL_MS = "60000"; + clearDeviceTracker(); + + await trackDevice("key-1", "203.0.113.5", "curl/8.0"); + const soon = Date.now() + 1000; + const expiredCount = expireDevices(soon); + + assert.equal(expiredCount, 0); + assert.equal(getDeviceCount("key-1"), 1); +}); + +// ─── Eviction under caps ──────────────────────────────────────────────────── + +test("trackDevice: enforces maxDevicesPerApiKey by evicting the oldest device", async () => { + process.env.DEVICE_TRACKER_MAX_DEVICES_PER_KEY = "2"; + clearDeviceTracker(); + + await trackDevice("key-1", "203.0.113.1", "ua-1"); + await new Promise((resolve) => setTimeout(resolve, 2)); + await trackDevice("key-1", "203.0.113.2", "ua-2"); + await new Promise((resolve) => setTimeout(resolve, 2)); + // Third distinct device should evict the oldest (203.0.113.1 / ua-1). + await trackDevice("key-1", "203.0.113.3", "ua-3"); + + assert.equal(getDeviceCount("key-1"), 2); + const uaSet = new Set(getDeviceDetails("key-1").map((d) => d.userAgent)); + assert.ok(!uaSet.has("ua-1"), "oldest device should have been evicted"); + assert.ok(uaSet.has("ua-2")); + assert.ok(uaSet.has("ua-3")); +}); + +test("trackDevice: enforces maxTotalDevices globally across all keys", async () => { + process.env.DEVICE_TRACKER_MAX_TOTAL_DEVICES = "2"; + clearDeviceTracker(); + + await trackDevice("key-1", "203.0.113.1", "ua-1"); + await new Promise((resolve) => setTimeout(resolve, 2)); + await trackDevice("key-2", "203.0.113.2", "ua-2"); + await new Promise((resolve) => setTimeout(resolve, 2)); + await trackDevice("key-3", "203.0.113.3", "ua-3"); + + const total = Object.values(getAllDeviceCounts()).reduce((a, b) => a + b, 0); + assert.equal(total, 2); + // key-1's device was the oldest globally and should be gone. + assert.equal(getDeviceCount("key-1"), 0); +}); + +// ─── IP masking ───────────────────────────────────────────────────────────── + +test("maskIp: masks the last two octets of an IPv4 address", () => { + assert.equal(maskIp("203.0.113.42"), "203.0.x.x"); +}); + +test("maskIp: masks an IPv6 address down to its first three groups", () => { + assert.equal(maskIp("2001:db8:85a3:0:0:8a2e:370:7334"), "2001:db8:85a3:..."); +}); + +test("maskIp: returns 'unknown' for missing/unknown input", () => { + assert.equal(maskIp(null), "unknown"); + assert.equal(maskIp(undefined), "unknown"); + assert.equal(maskIp("unknown"), "unknown"); +}); + +test("trackDevice: never stores the raw IP — only the masked form", async () => { + await trackDevice("key-1", "203.0.113.42", "curl/8.0"); + const [detail] = getDeviceDetails("key-1"); + + assert.equal(detail.ip, "203.0.x.x"); + assert.notEqual(detail.ip, "203.0.113.42"); +}); + +test("getDeviceDetails: truncates the fingerprint instead of exposing the full SHA-256 hash", async () => { + await trackDevice("key-1", "203.0.113.42", "curl/8.0"); + const [detail] = getDeviceDetails("key-1"); + + assert.equal(detail.fingerprint.length, 12); +}); + +// ─── IP extraction from headers ───────────────────────────────────────────── + +test("extractIpFromHeaders: prefers cf-connecting-ip over x-forwarded-for", () => { + const ip = extractIpFromHeaders({ + "cf-connecting-ip": "203.0.113.5", + "x-forwarded-for": "198.51.100.9, 10.0.0.1", + }); + assert.equal(ip, "203.0.113.5"); +}); + +test("extractIpFromHeaders: falls back to the first hop of x-forwarded-for", () => { + const ip = extractIpFromHeaders({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }); + assert.equal(ip, "198.51.100.9"); +}); + +test("extractIpFromHeaders: works with a real Headers instance", () => { + const headers = new Headers({ "x-real-ip": "192.0.2.7" }); + assert.equal(extractIpFromHeaders(headers), "192.0.2.7"); +}); + +test("extractIpFromHeaders: returns 'unknown' when no IP header is present", () => { + assert.equal(extractIpFromHeaders({}), "unknown"); + assert.equal(extractIpFromHeaders(null), "unknown"); +});