From deef12b5e4605d4c704ef4bfa7383083a27ae763 Mon Sep 17 00:00:00 2001
From: ding113 <h.ding.262@gmail.com>
Date: Mon, 23 Feb 2026 18:15:21 +0800
Subject: [PATCH 1/2] fix(dashboard): harden keyset cursor parsing and
 timestamp precision

Address review bot comments:
- parseKeysetCursor: add 1024 byte length limit, validate id is finite
  positive integer, reject NaN/negative/zero
- Keyset fallback: when cursor is not valid JSON but useKeyset is true,
  fall back to offset instead of silently resetting to page 1
- Timestamp precision: use date_trunc('milliseconds', col) in keyset
  comparisons to match JS Date encoding precision and prevent duplicate
  rows at page boundaries
- Cursor encoding: replace hardcoded name/createdAt ternary with
  extensible lookup map
- getUsersUsageBatch: filter negative/zero IDs in addition to non-integers
---
 src/actions/users.ts   |  4 +++-
 src/repository/user.ts | 21 ++++++++++++++++-----
 2 files changed, 19 insertions(+), 6 deletions(-)

diff --git a/src/actions/users.ts b/src/actions/users.ts
index 90a2168a2..768e7ec62 100644
--- a/src/actions/users.ts
+++ b/src/actions/users.ts
@@ -778,7 +778,9 @@ export async function getUsersUsageBatch(
       return { ok: true, data: { usageByKeyId: {} } };
     }
 
-    const sanitizedIds = Array.from(new Set(userIds)).filter((id) => Number.isInteger(id));
+    const sanitizedIds = Array.from(new Set(userIds)).filter(
+      (id) => Number.isInteger(id) && id > 0
+    );
     if (sanitizedIds.length === 0) {
       return { ok: true, data: { usageByKeyId: {} } };
     }
diff --git a/src/repository/user.ts b/src/repository/user.ts
index 2bf19a027..c5baf101d 100644
--- a/src/repository/user.ts
+++ b/src/repository/user.ts
@@ -161,10 +161,13 @@ interface KeysetCursor {
 }
 
 function parseKeysetCursor(raw: string): KeysetCursor | null {
+  if (raw.length > 1024) return null;
   try {
     const parsed = JSON.parse(raw);
     if (typeof parsed === "object" && parsed !== null && "v" in parsed && "id" in parsed) {
-      return { v: String(parsed.v), id: Number(parsed.id) };
+      const id = Number(parsed.id);
+      if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) return null;
+      return { v: String(parsed.v), id };
     }
   } catch {
     // not JSON - treat as offset
@@ -299,6 +302,7 @@ export async function findUserListBatch(
   // Keyset cursor: seek past the last row of the previous page.
   // ASC:  ORDER BY col ASC,  id ASC  -> WHERE (col, id) > (cv, cid)
   // DESC: ORDER BY col DESC, id ASC  -> mixed direction, must decompose manually
+  // Truncate timestamps to millisecond precision to match JS Date encoding.
   let offset = 0;
   if (cursor && useKeyset) {
     const keyset = parseKeysetCursor(cursor);
@@ -306,11 +310,12 @@ export async function findUserListBatch(
       if (sortBy === "createdAt") {
         const d = new Date(keyset.v);
         if (!Number.isNaN(d.getTime())) {
+          const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`;
           if (sortOrder === "asc") {
-            conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
+            conditions.push(sql`(${truncCol}, ${users.id}) > (${d}, ${keyset.id})`);
           } else {
             conditions.push(
-              sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))`
+              sql`(${truncCol} < ${d} OR (${truncCol} = ${d} AND ${users.id} > ${keyset.id}))`
             );
           }
         }
@@ -323,6 +328,9 @@ export async function findUserListBatch(
           );
         }
       }
+    } else {
+      // Cursor is not valid keyset JSON -- fall back to offset
+      offset = Math.max(Number(cursor) || 0, 0);
     }
   } else if (cursor) {
     // Offset fallback for nullable columns
@@ -369,8 +377,11 @@ export async function findUserListBatch(
   if (hasMore) {
     if (useKeyset) {
       const lastRow = usersToReturn[usersToReturn.length - 1];
-      const rawValue = sortBy === "name" ? lastRow.name : lastRow.createdAt;
-      nextCursor = encodeKeysetCursor(rawValue, lastRow.id);
+      const keysetRowValue: Record<string, unknown> = {
+        name: lastRow.name,
+        createdAt: lastRow.createdAt,
+      };
+      nextCursor = encodeKeysetCursor(keysetRowValue[sortBy], lastRow.id);
     } else {
       nextCursor = String(offset + effectiveLimit);
     }

From 82185070fb974ff8cbb9a403f9e29e8a179043e7 Mon Sep 17 00:00:00 2001
From: ding113 <h.ding.262@gmail.com>
Date: Mon, 23 Feb 2026 18:36:19 +0800
Subject: [PATCH 2/2] refactor(cache): rename ambiguous clearAllSessionsCache /
 clearAllSessionCache

- clearAllSessionsCache -> clearAllSessionsQueryCache (deletes "all_sessions" key only)
- clearAllSessionCache -> clearAllCaches (clears both cache instances entirely)

Update all call sites and test references.
---
 src/actions/active-sessions.ts                   |  8 ++++----
 src/lib/cache/session-cache.ts                   |  4 ++--
 .../active-sessions-special-settings.test.ts     |  2 +-
 tests/unit/lib/cache/session-cache.test.ts       | 16 ++++++++--------
 4 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts
index b3fc65e95..e03cb7731 100644
--- a/src/actions/active-sessions.ts
+++ b/src/actions/active-sessions.ts
@@ -864,12 +864,12 @@ export async function terminateActiveSession(sessionId: string): Promise<ActionR
     }
 
     // 4. 清除缓存
-    const { clearActiveSessionsCache, clearSessionDetailsCache, clearAllSessionsCache } =
+    const { clearActiveSessionsCache, clearSessionDetailsCache, clearAllSessionsQueryCache } =
       await import("@/lib/cache/session-cache");
 
     clearActiveSessionsCache();
     clearSessionDetailsCache(sessionId);
-    clearAllSessionsCache();
+    clearAllSessionsQueryCache();
 
     logger.info("Session terminated by user", {
       sessionId,
@@ -1012,11 +1012,11 @@ export async function terminateActiveSessionsBatch(
     const failedCount = allowedFailedCount + unauthorizedCount + missingCount;
 
     // 4. 清除缓存
-    const { clearActiveSessionsCache, clearAllSessionsCache, clearSessionDetailsCache } =
+    const { clearActiveSessionsCache, clearAllSessionsQueryCache, clearSessionDetailsCache } =
       await import("@/lib/cache/session-cache");
 
     clearActiveSessionsCache();
-    clearAllSessionsCache();
+    clearAllSessionsQueryCache();
 
     // 清除每个终止 Session 的详情缓存
     for (const sid of allowedSessionIds) {
diff --git a/src/lib/cache/session-cache.ts b/src/lib/cache/session-cache.ts
index 7a220218e..61de9bff6 100644
--- a/src/lib/cache/session-cache.ts
+++ b/src/lib/cache/session-cache.ts
@@ -183,7 +183,7 @@ export function clearActiveSessionsCache() {
 /**
  * 清空所有 Sessions 的缓存（包括活跃和非活跃）
  */
-export function clearAllSessionsCache() {
+export function clearAllSessionsQueryCache() {
   activeSessionsCache.delete("all_sessions");
 }
 
@@ -197,7 +197,7 @@ export function clearSessionDetailsCache(sessionId: string) {
 /**
  * 清空所有 Session 缓存
  */
-export function clearAllSessionCache() {
+export function clearAllCaches() {
   activeSessionsCache.clear();
   sessionDetailsCache.clear();
 }
diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts
index 11fc63c2a..2eaa32c05 100644
--- a/tests/unit/actions/active-sessions-special-settings.test.ts
+++ b/tests/unit/actions/active-sessions-special-settings.test.ts
@@ -31,7 +31,7 @@ vi.mock("@/lib/cache/session-cache", () => ({
   setSessionDetailsCache: setSessionDetailsCacheMock,
   clearActiveSessionsCache: vi.fn(),
   clearSessionDetailsCache: vi.fn(),
-  clearAllSessionsCache: vi.fn(),
+  clearAllSessionsQueryCache: vi.fn(),
 }));
 
 vi.mock("@/lib/logger", () => ({
diff --git a/tests/unit/lib/cache/session-cache.test.ts b/tests/unit/lib/cache/session-cache.test.ts
index 18183f78b..56e412f31 100644
--- a/tests/unit/lib/cache/session-cache.test.ts
+++ b/tests/unit/lib/cache/session-cache.test.ts
@@ -10,9 +10,9 @@ async function loadSessionCache() {
     getSessionDetailsCache: mod.getSessionDetailsCache,
     setSessionDetailsCache: mod.setSessionDetailsCache,
     clearActiveSessionsCache: mod.clearActiveSessionsCache,
-    clearAllSessionsCache: mod.clearAllSessionsCache,
+    clearAllSessionsQueryCache: mod.clearAllSessionsQueryCache,
     clearSessionDetailsCache: mod.clearSessionDetailsCache,
-    clearAllSessionCache: mod.clearAllSessionCache,
+    clearAllCaches: mod.clearAllCaches,
     startCacheCleanup: mod.startCacheCleanup,
     stopCacheCleanup: mod.stopCacheCleanup,
     getCacheStats: mod.getCacheStats,
@@ -125,9 +125,9 @@ describe("SessionCache（Session 数据缓存层）", () => {
       getSessionDetailsCache,
       setSessionDetailsCache,
       clearActiveSessionsCache,
-      clearAllSessionsCache,
+      clearAllSessionsQueryCache,
       clearSessionDetailsCache,
-      clearAllSessionCache,
+      clearAllCaches,
     } = await loadSessionCache();
 
     setActiveSessionsCache(
@@ -182,14 +182,14 @@ describe("SessionCache（Session 数据缓存层）", () => {
     clearActiveSessionsCache();
     expect(getActiveSessionsCache()).toBeNull();
 
-    clearAllSessionsCache();
-    // clearAllSessionsCache 删除 all_sessions，而非 active_sessions
+    clearAllSessionsQueryCache();
+    // clearAllSessionsQueryCache 删除 all_sessions，而非 active_sessions
     expect(getActiveSessionsCache("all_sessions")).toBeNull();
 
     clearSessionDetailsCache("s_1");
     expect(getSessionDetailsCache("s_1")).toBeNull();
 
-    // 再次写入后，clearAllSessionCache 应清空两类缓存
+    // 再次写入后，clearAllCaches 应清空两类缓存
     setActiveSessionsCache([], "active_sessions");
     setSessionDetailsCache("s_2", {
       sessionId: "s_2",
@@ -213,7 +213,7 @@ describe("SessionCache（Session 数据缓存层）", () => {
       cacheTtlApplied: null,
     });
 
-    clearAllSessionCache();
+    clearAllCaches();
     expect(getActiveSessionsCache()).toBeNull();
     expect(getSessionDetailsCache("s_2")).toBeNull();
   });
