From 33b754c79ec7c4b084b04bfac8e075441422ded9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Jul 2026 09:13:12 -0400 Subject: [PATCH] fix: wait out the live server's auth cache before the CLI's destructive cascade With AUTH_USER_CACHE_MODE=on and the in-process cache, markUserDeleting can only invalidate the CLI's own cache; a live server keeps serving a stale pre-barrier req.user for up to the 5s TTL, and a request from it could recreate conversations/files mid-cascade (only the schedule routes consult the Mongo barrier directly). The script now sleeps the full TTL plus slack after raising the barrier, which covers both cache topologies; the pause is free in a manual admin command. --- config/__tests__/delete-user.spec.js | 28 ++++++++++++++++++++++++++++ config/delete-user.js | 18 ++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/config/__tests__/delete-user.spec.js b/config/__tests__/delete-user.spec.js index e0e74076c9..0178d2edcc 100644 --- a/config/__tests__/delete-user.spec.js +++ b/config/__tests__/delete-user.spec.js @@ -19,6 +19,10 @@ const mockAskQuestion = jest.fn(); jest.mock('../connect', () => jest.fn(async () => {})); jest.mock('mongoose', () => ({ disconnect: jest.fn(async () => {}) })); +// The real package cannot load here: this suite mocks mongoose to a bare stub, and +// @librechat/api reads Types.ObjectId at import time. The live CLI harness exercises +// the real wiring; this suite only needs the constant. +jest.mock('@librechat/api', () => ({ AUTH_USER_DOC_CACHE_TTL_MS: 5000 })); jest.mock('@librechat/data-schemas', () => ({ createModels: () => new Proxy({}, { get: (_target, prop) => mockModelFor(prop) }), })); @@ -49,6 +53,7 @@ describe('Delete user CLI', () => { let logSpy; beforeEach(() => { + delete process.env.AUTH_USER_CACHE_MODE; process.argv = ['node', 'delete-user.js', 'deleted@example.com']; logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -109,6 +114,29 @@ describe('Delete user CLI', () => { expect(mockModelFor('Message').deleteMany).not.toHaveBeenCalled(); }); + it('waits out the live auth cache before any destructive read or write', async () => { + // In-process cache mode: markUserDeleting can only invalidate THIS process's + // cache, so a live server keeps serving a pre-barrier req.user for up to the + // cache TTL — long enough for a request to recreate data mid-cascade. The + // script must sleep through that window between the barrier and the cascade. + process.env.AUTH_USER_CACHE_MODE = 'on'; + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + try { + const started = Date.now(); + const code = await runCli(); + expect(code).toBe(0); + const waits = timeoutSpy.mock.calls.map((c) => c[1]).filter((ms) => ms >= 5000); + expect(waits.length).toBeGreaterThanOrEqual(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(5000); + const barrierAt = mockMarkUserDeleting.mock.invocationCallOrder[0]; + const countAt = mockModelFor('ScheduleRun').countDocuments.mock.invocationCallOrder[0]; + expect(barrierAt).toBeLessThan(countAt); + } finally { + delete process.env.AUTH_USER_CACHE_MODE; + timeoutSpy.mockRestore(); + } + }, 20000); + it('refuses while a scheduled run is still active', async () => { mockModelFor('ScheduleRun').countDocuments.mockResolvedValue(2); diff --git a/config/delete-user.js b/config/delete-user.js index 55afa2e144..d3d56dd852 100644 --- a/config/delete-user.js +++ b/config/delete-user.js @@ -31,6 +31,7 @@ const { ScheduleRun, } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); +const { AUTH_USER_DOC_CACHE_TTL_MS } = require('@librechat/api'); const { markUserDeleting } = require('~/models'); const { askQuestion, silentExit } = require('./helpers'); const connect = require('./connect'); @@ -89,10 +90,6 @@ async function gracefulExit(code = 0) { // Through markUserDeleting, never a raw update: that method also drops the auth // user-doc cache entry, and fails closed — a cache it cannot reach aborts the // deletion rather than proceeding behind a barrier that was never actually raised. - // With a shared (Redis) cache this invalidation reaches the live server. With the - // in-process cache no external script can, but the residual window is bounded by the - // 5s AUTH_USER_DOC_CACHE_TTL_MS, and the schedule-fire guard (isOwnerDeleting) reads - // Mongo directly rather than trusting req.user. try { await markUserDeleting(uid); } catch (err) { @@ -101,6 +98,19 @@ async function gracefulExit(code = 0) { return gracefulExit(1); } + // WAIT OUT the live server's auth cache before anything destructive. With a shared + // (Redis) cache the invalidation above reached the server; with the IN-PROCESS cache + // no external script can, so a request served from a stale pre-barrier req.user could + // still mutate conversations/files during the cascade and recreate data for the + // removed account — only the schedule routes consult the Mongo barrier directly. + // Sleeping through the full TTL (plus slack) is the one mechanism that covers both + // topologies; this is a manual admin command, so the pause is free. + if (process.env.AUTH_USER_CACHE_MODE === 'on') { + const waitMs = AUTH_USER_DOC_CACHE_TTL_MS + 1000; + console.orange(`Waiting ${waitMs}ms for any live server's auth cache to expire...`); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + // REFUSE rather than warn when a scheduled run is in flight. This script talks to the // database directly, so unlike the HTTP deletion paths it cannot abort a live loopback // generation or wait for it to drain. That generation can already have passed its