From deef12b5e4605d4c704ef4bfa7383083a27ae763 Mon Sep 17 00:00:00 2001 From: ding113 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 = { + 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 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 ({ 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(); });