feat(api-keys): add per-key device/connection tracking (#5998)

* feat(api-keys): add per-key device/connection tracking

Tracks distinct client devices (SHA-256 fingerprint of IP + User-Agent)
seen with each API key, with a 30-minute TTL and per-key/global caps. The
tracker is in-memory only (module-scoped Map, same pattern as
sessionManager.ts — no global.* singleton) and never stores the raw IP:
it is masked before being written.

Hooked into open-sse/handlers/chatCore.ts (the real chat entry) rather
than the legacy src/sse/handlers path. New GET /api/keys/[id]/devices
management route exposes masked device details for a key, and the
API Keys dashboard tab gets a "Devices" count badge alongside the
existing Sessions badge.

This is a new granularity distinct from the existing maxSessions cap
(src/lib/db/apiKeys.ts), which limits concurrent sticky-routing sessions
rather than tracking device identity.

Co-authored-by: Muhammad Mugni Hadi <mugni@rukita.co>
Inspired-by: https://github.com/decolua/9router/pull/931

* chore(changelog): restore release entries + add api-keys device-tracking bullet

---------

Co-authored-by: Muhammad Mugni Hadi <mugni@rukita.co>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 00:08:17 -03:00
committed by GitHub
parent bff75cbd3c
commit b6a11f04df
7 changed files with 602 additions and 0 deletions

View File

@@ -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

View File

@@ -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?.(

View File

@@ -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<fingerprint, DeviceRecord>
const devicesByApiKey = new Map<string, Map<string, DeviceRecord>>();
let cleanupTimer: ReturnType<typeof setInterval> | 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<string, DeviceRecord>): 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<string, unknown> | 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<string, number> {
expireDevices();
const counts: Record<string, number> = {};
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);
}

View File

@@ -222,6 +222,7 @@ export default function ApiManagerPageClient() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
const [deviceCounts, setDeviceCounts] = useState<Record<string, number>>({});
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}
</span>
)}
{deviceCount > 0 && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 text-[11px] font-medium"
title={t("devicesTooltip", { count: deviceCount })}
>
<span className="material-symbols-outlined text-[12px]">devices</span>
{t("devicesCount", { count: deviceCount })}
</span>
)}
{hasThrottle && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">speed</span>+

View File

@@ -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 });
}
}

View File

@@ -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",

View File

@@ -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");
});