From a0f9782e6082675a4e2ae7e07a61d5094fcc1fbb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 25 Feb 2026 17:41:23 -0500 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=AA=A3=20fix:=20Prevent=20Memory=20Re?= =?UTF-8?q?tention=20from=20AsyncLocalStorage=20Context=20Propagation=20(#?= =?UTF-8?q?11942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: store hide_sequential_outputs before processStream clears config processStream now clears config.configurable after completion to break memory retention chains. Save hide_sequential_outputs to a local variable before calling runAgents so the post-stream filter still works. * feat: memory diagnostics * chore: expose garbage collection in backend inspect command Updated the backend inspect command in package.json to include the --expose-gc flag, enabling garbage collection diagnostics for improved memory management during development. * chore: update @librechat/agents dependency to version 3.1.52 Bumped the version of @librechat/agents in package.json and package-lock.json to ensure compatibility and access to the latest features and fixes. * fix: clear heavy config state after processStream to prevent memory leaks Break the reference chain from LangGraph's internal __pregel_scratchpad through @langchain/core RunTree.extra[lc:child_config] into the AsyncLocalStorage context captured by timers and I/O handles. After stream completion, null out symbol-keyed scratchpad properties (currentTaskInput), config.configurable, and callbacks. Also call Graph.clearHeavyState() to release config, signal, content maps, handler registry, and tool sessions. * chore: fix imports for memory utils * chore: add circular dependency check in API build step Enhanced the backend review workflow to include a check for circular dependencies during the API build process. If a circular dependency is detected, an error message is displayed, and the process exits with a failure status. * chore: update API build step to include circular dependency detection Modified the backend review workflow to rename the API package installation step to reflect its new functionality, which now includes detection of circular dependencies during the build process. * chore: add memory diagnostics option to .env.example Included a commented-out configuration option for enabling memory diagnostics in the .env.example file, which logs heap and RSS snapshots every 60 seconds when activated. * chore: remove redundant agentContexts cleanup in disposeClient function Streamlined the disposeClient function by eliminating duplicate cleanup logic for agentContexts, ensuring efficient memory management during client disposal. * refactor: move runOutsideTracing utility to utils and update its usage Refactored the runOutsideTracing function by relocating it to the utils module for better organization. Updated the tool execution handler to utilize the new import, ensuring consistent tracing behavior during tool execution. * refactor: enhance connection management and diagnostics Added a method to ConnectionsRepository for retrieving the active connection count. Updated UserConnectionManager to utilize this new method for app connection count reporting. Refined the OAuthReconnectionTracker's getStats method to improve clarity in diagnostics. Introduced a new tracing utility in the utils module to streamline tracing context management. Additionally, added a safeguard in memory diagnostics to prevent unnecessary snapshot collection for very short intervals. * refactor: enhance tracing utility and add memory diagnostics tests Refactored the runOutsideTracing function to improve warning logic when the AsyncLocalStorage context is missing. Added tests for memory diagnostics and tracing utilities to ensure proper functionality and error handling. Introduced a new test suite for memory diagnostics, covering snapshot collection and garbage collection behavior. --- .env.example | 3 + .github/workflows/backend-review.yml | 10 +- api/package.json | 2 +- api/server/cleanup.js | 12 +- api/server/controllers/agents/client.js | 3 +- api/server/index.js | 8 +- package-lock.json | 10 +- package.json | 2 +- packages/api/package.json | 2 +- packages/api/src/agents/handlers.ts | 205 ++++++++++-------- packages/api/src/index.ts | 2 + packages/api/src/mcp/ConnectionsRepository.ts | 5 + packages/api/src/mcp/UserConnectionManager.ts | 19 ++ packages/api/src/mcp/connection.ts | 15 +- .../src/mcp/oauth/OAuthReconnectionManager.ts | 4 + .../src/mcp/oauth/OAuthReconnectionTracker.ts | 13 ++ .../api/src/stream/GenerationJobManager.ts | 13 ++ .../api/src/utils/__tests__/memory.test.ts | 173 +++++++++++++++ .../api/src/utils/__tests__/tracing.test.ts | 137 ++++++++++++ packages/api/src/utils/index.ts | 1 + packages/api/src/utils/memory.ts | 150 +++++++++++++ packages/api/src/utils/tracing.ts | 31 +++ 22 files changed, 704 insertions(+), 116 deletions(-) create mode 100644 packages/api/src/utils/__tests__/memory.test.ts create mode 100644 packages/api/src/utils/__tests__/tracing.test.ts create mode 100644 packages/api/src/utils/memory.ts create mode 100644 packages/api/src/utils/tracing.ts diff --git a/.env.example b/.env.example index f6d2ec271f..3e94a0c63a 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,9 @@ CONSOLE_JSON=false DEBUG_LOGGING=true DEBUG_CONSOLE=false +# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect) +# MEM_DIAG=true + #=============# # Permissions # #=============# diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 2379b8fee7..e151087790 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -42,8 +42,14 @@ jobs: - name: Install Data Schemas Package run: npm run build:data-schemas - - name: Install API Package - run: npm run build:api + - name: Build API Package & Detect Circular Dependencies + run: | + output=$(npm run build:api 2>&1) + echo "$output" + if echo "$output" | grep -q "Circular depend"; then + echo "Error: Circular dependency detected in @librechat/api!" + exit 1 + fi - name: Create empty auth.json file run: | diff --git a/api/package.json b/api/package.json index 7c3c1045ed..1447087b38 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/cleanup.js b/api/server/cleanup.js index c482a2267e..364c02cd8a 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -35,7 +35,6 @@ const graphPropsToClean = [ 'tools', 'signal', 'config', - 'agentContexts', 'messages', 'contentData', 'stepKeyIds', @@ -277,7 +276,16 @@ function disposeClient(client) { if (client.run) { if (client.run.Graph) { - client.run.Graph.resetValues(); + if (typeof client.run.Graph.clearHeavyState === 'function') { + client.run.Graph.clearHeavyState(); + } else { + client.run.Graph.resetValues(); + } + + if (client.run.Graph.agentContexts) { + client.run.Graph.agentContexts.clear(); + client.run.Graph.agentContexts = null; + } graphPropsToClean.forEach((prop) => { if (client.run.Graph[prop] !== undefined) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 49240a6b3b..7aea6d1e8f 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -891,9 +891,10 @@ class AgentClient extends BaseClient { config.signal = null; }; + const hideSequentialOutputs = config.configurable.hide_sequential_outputs; await runAgents(initialMessages); /** @deprecated Agent Chain */ - if (config.configurable.hide_sequential_outputs) { + if (hideSequentialOutputs) { this.contentParts = this.contentParts.filter((part, index) => { // Include parts that are either: // 1. At or after the finalContentStart index diff --git a/api/server/index.js b/api/server/index.js index 193eb423ad..2aff26ceaf 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -13,11 +13,12 @@ const mongoSanitize = require('express-mongo-sanitize'); const { isEnabled, ErrorController, + memoryDiagnostics, performStartupChecks, handleJsonParseError, - initializeFileStorage, GenerationJobManager, createStreamServices, + initializeFileStorage, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -201,6 +202,11 @@ const startServer = async () => { const streamServices = createStreamServices(); GenerationJobManager.configure(streamServices); GenerationJobManager.initialize(); + + const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect')); + if (inspectFlags || isEnabled(process.env.MEM_DIAG)) { + memoryDiagnostics.start(); + } }); }; diff --git a/package-lock.json b/package-lock.json index 3a875f9fb7..bbb379c4d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11859,9 +11859,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.51", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.51.tgz", - "integrity": "sha512-inEcLCuD7YF0yCBrnxCgemg2oyRWJtCq49tLtokrD+WyWT97benSB+UyopjWh5woOsxSws3oc60d5mxRtifoLg==", + "version": "3.1.52", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.52.tgz", + "integrity": "sha512-Bg35zp+vEDZ0AEJQPZ+ukWb/UqBrsLcr3YQWRQpuvpftEgfQz0fHM5Wrxn6l5P7PvaD1ViolxoG44nggjCt7Hw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -43719,7 +43719,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/package.json b/package.json index 4791843326..02a46df399 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "update-banner": "node config/update-banner.js", "delete-banner": "node config/delete-banner.js", "backend": "cross-env NODE_ENV=production node api/server/index.js", - "backend:inspect": "cross-env NODE_ENV=production node --inspect api/server/index.js", + "backend:inspect": "cross-env NODE_ENV=production node --inspect --expose-gc api/server/index.js", "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js", "backend:experimental": "cross-env NODE_ENV=production node api/server/experimental.js", "backend:stop": "node config/stop-backend.js", diff --git a/packages/api/package.json b/packages/api/package.json index 8e55d8d901..1854457b42 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -90,7 +90,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 62200b1a46..07c68c9d8a 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -9,6 +9,7 @@ import type { ToolExecuteBatchRequest, } from '@librechat/agents'; import type { StructuredToolInterface } from '@langchain/core/tools'; +import { runOutsideTracing } from '~/utils'; export interface ToolEndCallbackData { output: { @@ -57,110 +58,122 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand const { toolCalls, agentId, configurable, metadata, resolve, reject } = data; try { - const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; - const { loadedTools, configurable: toolConfigurable } = await loadTools(toolNames, agentId); - const toolMap = new Map(loadedTools.map((t) => [t.name, t])); - const mergedConfigurable = { ...configurable, ...toolConfigurable }; + await runOutsideTracing(async () => { + try { + const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; + const { loadedTools, configurable: toolConfigurable } = await loadTools( + toolNames, + agentId, + ); + const toolMap = new Map(loadedTools.map((t) => [t.name, t])); + const mergedConfigurable = { ...configurable, ...toolConfigurable }; - const results: ToolExecuteResult[] = await Promise.all( - toolCalls.map(async (tc: ToolCallRequest) => { - const tool = toolMap.get(tc.name); + const results: ToolExecuteResult[] = await Promise.all( + toolCalls.map(async (tc: ToolCallRequest) => { + const tool = toolMap.get(tc.name); - if (!tool) { - logger.warn( - `[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`, - ); - return { - toolCallId: tc.id, - status: 'error' as const, - content: '', - errorMessage: `Tool ${tc.name} not found`, - }; - } - - try { - const toolCallConfig: Record = { - id: tc.id, - stepId: tc.stepId, - turn: tc.turn, - }; - - if ( - tc.codeSessionContext && - (tc.name === Constants.EXECUTE_CODE || - tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) - ) { - toolCallConfig.session_id = tc.codeSessionContext.session_id; - if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) { - toolCallConfig._injected_files = tc.codeSessionContext.files; - } - } - - if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) { - const toolRegistry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined; - const ptcToolMap = mergedConfigurable?.ptcToolMap as - | Map - | undefined; - if (toolRegistry) { - const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter( - (t) => - t.name !== Constants.PROGRAMMATIC_TOOL_CALLING && - t.name !== Constants.TOOL_SEARCH, + if (!tool) { + logger.warn( + `[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`, ); - toolCallConfig.toolDefs = toolDefs; - toolCallConfig.toolMap = ptcToolMap ?? toolMap; + return { + toolCallId: tc.id, + status: 'error' as const, + content: '', + errorMessage: `Tool ${tc.name} not found`, + }; } - } - const result = await tool.invoke(tc.args, { - toolCall: toolCallConfig, - configurable: mergedConfigurable, - metadata, - } as Record); + try { + const toolCallConfig: Record = { + id: tc.id, + stepId: tc.stepId, + turn: tc.turn, + }; - if (toolEndCallback) { - await toolEndCallback( - { - output: { - name: tc.name, - tool_call_id: tc.id, - content: result.content, - artifact: result.artifact, - }, - }, - { - run_id: (metadata as Record)?.run_id as string | undefined, - thread_id: (metadata as Record)?.thread_id as - | string - | undefined, - ...metadata, - }, - ); - } + if ( + tc.codeSessionContext && + (tc.name === Constants.EXECUTE_CODE || + tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) + ) { + toolCallConfig.session_id = tc.codeSessionContext.session_id; + if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) { + toolCallConfig._injected_files = tc.codeSessionContext.files; + } + } - return { - toolCallId: tc.id, - content: result.content, - artifact: result.artifact, - status: 'success' as const, - }; - } catch (toolError) { - const error = toolError as Error; - logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error); - return { - toolCallId: tc.id, - status: 'error' as const, - content: '', - errorMessage: error.message, - }; - } - }), - ); + if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) { + const toolRegistry = mergedConfigurable?.toolRegistry as + | LCToolRegistry + | undefined; + const ptcToolMap = mergedConfigurable?.ptcToolMap as + | Map + | undefined; + if (toolRegistry) { + const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter( + (t) => + t.name !== Constants.PROGRAMMATIC_TOOL_CALLING && + t.name !== Constants.TOOL_SEARCH, + ); + toolCallConfig.toolDefs = toolDefs; + toolCallConfig.toolMap = ptcToolMap ?? toolMap; + } + } - resolve(results); - } catch (error) { - logger.error('[ON_TOOL_EXECUTE] Fatal error:', error); - reject(error as Error); + const result = await tool.invoke(tc.args, { + toolCall: toolCallConfig, + configurable: mergedConfigurable, + metadata, + } as Record); + + if (toolEndCallback) { + await toolEndCallback( + { + output: { + name: tc.name, + tool_call_id: tc.id, + content: result.content, + artifact: result.artifact, + }, + }, + { + run_id: (metadata as Record)?.run_id as string | undefined, + thread_id: (metadata as Record)?.thread_id as + | string + | undefined, + ...metadata, + }, + ); + } + + return { + toolCallId: tc.id, + content: result.content, + artifact: result.artifact, + status: 'success' as const, + }; + } catch (toolError) { + const error = toolError as Error; + logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error); + return { + toolCallId: tc.id, + status: 'error' as const, + content: '', + errorMessage: error.message, + }; + } + }), + ); + + resolve(results); + } catch (error) { + logger.error('[ON_TOOL_EXECUTE] Fatal error:', error); + reject(error as Error); + } + }); + } catch (outerError) { + logger.error('[ON_TOOL_EXECUTE] Unexpected error:', outerError); + reject(outerError as Error); } }, }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fefdafaefd..a7edb3882d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -43,6 +43,8 @@ export * from './web'; export * from './cache'; /* Stream */ export * from './stream'; +/* Diagnostics */ +export { memoryDiagnostics } from './utils/memory'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; diff --git a/packages/api/src/mcp/ConnectionsRepository.ts b/packages/api/src/mcp/ConnectionsRepository.ts index b14af57b29..3e0c2aca2d 100644 --- a/packages/api/src/mcp/ConnectionsRepository.ts +++ b/packages/api/src/mcp/ConnectionsRepository.ts @@ -25,6 +25,11 @@ export class ConnectionsRepository { this.oauthOpts = oauthOpts; } + /** Returns the number of active connections in this repository */ + public getConnectionCount(): number { + return this.connections.size; + } + /** Checks whether this repository can connect to a specific server */ async has(serverName: string): Promise { const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, this.ownerId); diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index e5d94689a0..1b90072618 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -237,4 +237,23 @@ export abstract class UserConnectionManager { } } } + + /** Returns counts of tracked users and connections for diagnostics */ + public getConnectionStats(): { + trackedUsers: number; + totalConnections: number; + activityEntries: number; + appConnectionCount: number; + } { + let totalConnections = 0; + for (const serverMap of this.userConnections.values()) { + totalConnections += serverMap.size; + } + return { + trackedUsers: this.userConnections.size, + totalConnections, + activityEntries: this.userLastActivity.size, + appConnectionCount: this.appConnections?.getConnectionCount() ?? 0, + }; + } } diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 5744059708..8ac55224f8 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -18,10 +18,11 @@ import type { Response as UndiciResponse, } from 'undici'; import type { MCPOAuthTokens } from './oauth/types'; -import { withTimeout } from '~/utils/promise'; import type * as t from './types'; import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth'; +import { runOutsideTracing } from '~/utils/tracing'; import { sanitizeUrlForLogging } from './utils'; +import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -698,14 +699,16 @@ export class MCPConnection extends EventEmitter { await this.closeAgents(); } - this.transport = await this.constructTransport(this.options); + this.transport = await runOutsideTracing(() => this.constructTransport(this.options)); this.setupTransportDebugHandlers(); const connectTimeout = this.options.initTimeout ?? 120000; - await withTimeout( - this.client.connect(this.transport), - connectTimeout, - `Connection timeout after ${connectTimeout}ms`, + await runOutsideTracing(() => + withTimeout( + this.client.connect(this.transport!), + connectTimeout, + `Connection timeout after ${connectTimeout}ms`, + ), ); this.connectionState = 'connected'; diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts index ca9ce5c71f..f14c4abf15 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts @@ -147,6 +147,10 @@ export class OAuthReconnectionManager { } } + public getTrackerStats() { + return this.reconnectionsTracker.getStats(); + } + private async canReconnect(userId: string, serverName: string) { if (this.mcpManager == null) { return false; diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts b/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts index b65f8ad115..9f6ef4abd3 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts @@ -86,4 +86,17 @@ export class OAuthReconnectionTracker { const key = `${userId}:${serverName}`; this.activeTimestamps.delete(key); } + + /** Returns map sizes for diagnostics */ + public getStats(): { + usersWithFailedServers: number; + usersWithActiveReconnections: number; + activeTimestamps: number; + } { + return { + usersWithFailedServers: this.failed.size, + usersWithActiveReconnections: this.active.size, + activeTimestamps: this.activeTimestamps.size, + }; + } } diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 815133d616..cd5ff04eb0 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -1142,6 +1142,19 @@ class GenerationJobManagerClass { return this.jobStore.getJobCount(); } + /** Returns sizes of internal runtime maps for diagnostics */ + getRuntimeStats(): { + runtimeStateSize: number; + runStepBufferSize: number; + eventTransportStreams: number; + } { + return { + runtimeStateSize: this.runtimeState.size, + runStepBufferSize: this.runStepBuffers?.size ?? 0, + eventTransportStreams: this.eventTransport.getTrackedStreamIds().length, + }; + } + /** * Get job count by status. */ diff --git a/packages/api/src/utils/__tests__/memory.test.ts b/packages/api/src/utils/__tests__/memory.test.ts new file mode 100644 index 0000000000..c821088856 --- /dev/null +++ b/packages/api/src/utils/__tests__/memory.test.ts @@ -0,0 +1,173 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('~/stream', () => ({ + GenerationJobManager: { + getRuntimeStats: jest.fn(() => null), + }, +})); + +jest.mock('~/mcp/oauth/OAuthReconnectionManager', () => ({ + OAuthReconnectionManager: { + getInstance: jest.fn(() => ({ + getTrackerStats: jest.fn(() => null), + })), + }, +})); + +jest.mock('~/mcp/MCPManager', () => ({ + MCPManager: { + getInstance: jest.fn(() => ({ + getConnectionStats: jest.fn(() => null), + })), + }, +})); + +import { logger } from '@librechat/data-schemas'; +import { memoryDiagnostics } from '../memory'; + +type MockFn = jest.Mock; + +const debugMock = logger.debug as unknown as MockFn; +const infoMock = logger.info as unknown as MockFn; +const warnMock = logger.warn as unknown as MockFn; + +function callsContaining(mock: MockFn, substring: string): unknown[][] { + return mock.mock.calls.filter( + (args) => typeof args[0] === 'string' && (args[0] as string).includes(substring), + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + memoryDiagnostics.stop(); + + const snaps = memoryDiagnostics.getSnapshots() as unknown[]; + snaps.length = 0; +}); + +afterEach(() => { + memoryDiagnostics.stop(); + jest.useRealTimers(); +}); + +describe('memoryDiagnostics', () => { + describe('collectSnapshot', () => { + it('pushes a snapshot with expected shape', () => { + memoryDiagnostics.collectSnapshot(); + + const snaps = memoryDiagnostics.getSnapshots(); + expect(snaps).toHaveLength(1); + expect(snaps[0]).toEqual( + expect.objectContaining({ + ts: expect.any(Number), + rss: expect.any(Number), + heapUsed: expect.any(Number), + heapTotal: expect.any(Number), + external: expect.any(Number), + arrayBuffers: expect.any(Number), + }), + ); + }); + + it('caps history at 120 snapshots', () => { + for (let i = 0; i < 130; i++) { + memoryDiagnostics.collectSnapshot(); + } + expect(memoryDiagnostics.getSnapshots()).toHaveLength(120); + }); + + it('does not log trend with fewer than 3 snapshots', () => { + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + + expect(callsContaining(debugMock, 'Trend')).toHaveLength(0); + }); + + it('skips trend when elapsed time is under 0.1 minutes', () => { + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + + expect(callsContaining(debugMock, 'Trend')).toHaveLength(0); + }); + + it('logs trend data when enough time has elapsed', () => { + memoryDiagnostics.collectSnapshot(); + + jest.advanceTimersByTime(7_000); + memoryDiagnostics.collectSnapshot(); + + jest.advanceTimersByTime(7_000); + memoryDiagnostics.collectSnapshot(); + + const trendCalls = callsContaining(debugMock, 'Trend'); + expect(trendCalls.length).toBeGreaterThanOrEqual(1); + + const trendPayload = trendCalls[0][1] as Record; + expect(trendPayload).toHaveProperty('rssRate'); + expect(trendPayload).toHaveProperty('heapRate'); + expect(trendPayload.rssRate).toMatch(/MB\/hr$/); + expect(trendPayload.heapRate).toMatch(/MB\/hr$/); + expect(trendPayload.rssRate).not.toBe('Infinity MB/hr'); + expect(trendPayload.heapRate).not.toBe('Infinity MB/hr'); + }); + }); + + describe('start / stop', () => { + it('start is idempotent — calling twice does not create two intervals', () => { + memoryDiagnostics.start(); + memoryDiagnostics.start(); + + expect(callsContaining(infoMock, 'Starting')).toHaveLength(1); + }); + + it('stop is idempotent — calling twice does not error', () => { + memoryDiagnostics.start(); + memoryDiagnostics.stop(); + memoryDiagnostics.stop(); + + expect(callsContaining(infoMock, 'Stopped')).toHaveLength(1); + }); + + it('collects an immediate snapshot on start', () => { + expect(memoryDiagnostics.getSnapshots()).toHaveLength(0); + memoryDiagnostics.start(); + expect(memoryDiagnostics.getSnapshots().length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('forceGC', () => { + it('returns false and warns when gc is not exposed', () => { + const origGC = global.gc; + global.gc = undefined; + + const result = memoryDiagnostics.forceGC(); + + expect(result).toBe(false); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('GC not exposed')); + + global.gc = origGC; + }); + + it('calls gc and returns true when gc is exposed', () => { + const mockGC = jest.fn(); + global.gc = mockGC; + + const result = memoryDiagnostics.forceGC(); + + expect(result).toBe(true); + expect(mockGC).toHaveBeenCalledTimes(1); + expect(infoMock).toHaveBeenCalledWith(expect.stringContaining('Forced garbage collection')); + + global.gc = undefined; + }); + }); +}); diff --git a/packages/api/src/utils/__tests__/tracing.test.ts b/packages/api/src/utils/__tests__/tracing.test.ts new file mode 100644 index 0000000000..679b28e327 --- /dev/null +++ b/packages/api/src/utils/__tests__/tracing.test.ts @@ -0,0 +1,137 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage'); +const typedGlobal = globalThis as typeof globalThis & Record>; + +let originalStorage: AsyncLocalStorage | undefined; + +beforeEach(() => { + originalStorage = typedGlobal[TRACING_ALS_KEY]; + jest.restoreAllMocks(); +}); + +afterEach(() => { + if (originalStorage) { + typedGlobal[TRACING_ALS_KEY] = originalStorage; + } else { + delete typedGlobal[TRACING_ALS_KEY]; + } + delete process.env.LANGCHAIN_TRACING_V2; +}); + +async function freshImport(): Promise { + jest.resetModules(); + return import('../tracing'); +} + +describe('runOutsideTracing', () => { + it('clears the ALS context to undefined inside fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + let captured: string | undefined = 'NOT_CLEARED'; + als.run('should-not-propagate', () => { + runOutsideTracing(() => { + captured = als.getStore(); + }); + }); + + expect(captured).toBeUndefined(); + }); + + it('returns the value produced by fn (sync)', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + const result = als.run('ctx', () => runOutsideTracing(() => 42)); + expect(result).toBe(42); + }); + + it('returns the promise produced by fn (async)', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + const result = await als.run('ctx', () => + runOutsideTracing(async () => { + await Promise.resolve(); + return 'async-value'; + }), + ); + expect(result).toBe('async-value'); + }); + + it('propagates sync errors thrown inside fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + expect(() => + runOutsideTracing(() => { + throw new Error('boom'); + }), + ).toThrow('boom'); + }); + + it('propagates async rejections from fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + await expect( + runOutsideTracing(async () => { + throw new Error('async-boom'); + }), + ).rejects.toThrow('async-boom'); + }); + + it('falls back to fn() when ALS is not on globalThis', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + + const { runOutsideTracing } = await freshImport(); + + const result = runOutsideTracing(() => 'fallback'); + expect(result).toBe('fallback'); + }); + + it('does not warn when LANGCHAIN_TRACING_V2 is not set', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + delete process.env.LANGCHAIN_TRACING_V2; + + const warnSpy = jest.fn(); + jest.resetModules(); + jest.doMock('@librechat/data-schemas', () => ({ + logger: { warn: warnSpy }, + })); + const { runOutsideTracing } = await import('../tracing'); + + runOutsideTracing(() => 'ok'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns once when LANGCHAIN_TRACING_V2 is set but ALS is missing', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + process.env.LANGCHAIN_TRACING_V2 = 'true'; + + const warnSpy = jest.fn(); + jest.resetModules(); + jest.doMock('@librechat/data-schemas', () => ({ + logger: { warn: warnSpy }, + })); + const { runOutsideTracing } = await import('../tracing'); + + runOutsideTracing(() => 'first'); + runOutsideTracing(() => 'second'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('LANGCHAIN_TRACING_V2 is set but ALS not found'), + ); + }); +}); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index d4351eb5a0..470780cd5c 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -25,3 +25,4 @@ export * from './http'; export * from './tokens'; export * from './url'; export * from './message'; +export * from './tracing'; diff --git a/packages/api/src/utils/memory.ts b/packages/api/src/utils/memory.ts new file mode 100644 index 0000000000..214548d14b --- /dev/null +++ b/packages/api/src/utils/memory.ts @@ -0,0 +1,150 @@ +import { logger } from '@librechat/data-schemas'; +import { GenerationJobManager } from '~/stream'; +import { OAuthReconnectionManager } from '~/mcp/oauth/OAuthReconnectionManager'; +import { MCPManager } from '~/mcp/MCPManager'; + +type ConnectionStats = ReturnType['getConnectionStats']>; +type TrackerStats = ReturnType['getTrackerStats']>; +type RuntimeStats = ReturnType<(typeof GenerationJobManager)['getRuntimeStats']>; + +const INTERVAL_MS = 60_000; +const SNAPSHOT_HISTORY_LIMIT = 120; + +interface MemorySnapshot { + ts: number; + rss: number; + heapUsed: number; + heapTotal: number; + external: number; + arrayBuffers: number; + mcpConnections: ConnectionStats | null; + oauthTracker: TrackerStats | null; + generationJobs: RuntimeStats | null; +} + +const snapshots: MemorySnapshot[] = []; +let interval: NodeJS.Timeout | null = null; + +function toMB(bytes: number): string { + return (bytes / 1024 / 1024).toFixed(2); +} + +function getMCPStats(): { + mcpConnections: ConnectionStats | null; + oauthTracker: TrackerStats | null; +} { + let mcpConnections: ConnectionStats | null = null; + let oauthTracker: TrackerStats | null = null; + + try { + mcpConnections = MCPManager.getInstance().getConnectionStats(); + } catch { + /* not initialized yet */ + } + + try { + oauthTracker = OAuthReconnectionManager.getInstance().getTrackerStats(); + } catch { + /* not initialized yet */ + } + + return { mcpConnections, oauthTracker }; +} + +function getJobStats(): { generationJobs: RuntimeStats | null } { + try { + return { generationJobs: GenerationJobManager.getRuntimeStats() }; + } catch { + return { generationJobs: null }; + } +} + +function collectSnapshot(): void { + const mem = process.memoryUsage(); + const mcpStats = getMCPStats(); + const jobStats = getJobStats(); + + const snapshot: MemorySnapshot = { + ts: Date.now(), + rss: mem.rss, + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + external: mem.external, + arrayBuffers: mem.arrayBuffers ?? 0, + ...mcpStats, + ...jobStats, + }; + + snapshots.push(snapshot); + if (snapshots.length > SNAPSHOT_HISTORY_LIMIT) { + snapshots.shift(); + } + + logger.debug('[MemDiag] Snapshot', { + rss: `${toMB(mem.rss)} MB`, + heapUsed: `${toMB(mem.heapUsed)} MB`, + heapTotal: `${toMB(mem.heapTotal)} MB`, + external: `${toMB(mem.external)} MB`, + arrayBuffers: `${toMB(mem.arrayBuffers ?? 0)} MB`, + mcp: mcpStats, + jobs: jobStats, + snapshotCount: snapshots.length, + }); + + if (snapshots.length < 3) { + return; + } + + const first = snapshots[0]; + const last = snapshots[snapshots.length - 1]; + const elapsedMin = (last.ts - first.ts) / 60_000; + if (elapsedMin < 0.1) { + return; + } + const rssDelta = last.rss - first.rss; + const heapDelta = last.heapUsed - first.heapUsed; + logger.debug('[MemDiag] Trend', { + overMinutes: elapsedMin.toFixed(1), + rssDelta: `${toMB(rssDelta)} MB`, + heapDelta: `${toMB(heapDelta)} MB`, + rssRate: `${toMB((rssDelta / elapsedMin) * 60)} MB/hr`, + heapRate: `${toMB((heapDelta / elapsedMin) * 60)} MB/hr`, + }); +} + +function forceGC(): boolean { + if (global.gc) { + global.gc(); + logger.info('[MemDiag] Forced garbage collection'); + return true; + } + logger.warn('[MemDiag] GC not exposed. Start with --expose-gc to enable.'); + return false; +} + +function getSnapshots(): readonly MemorySnapshot[] { + return snapshots; +} + +function start(): void { + if (interval) { + return; + } + logger.info(`[MemDiag] Starting memory diagnostics (interval: ${INTERVAL_MS / 1000}s)`); + collectSnapshot(); + interval = setInterval(collectSnapshot, INTERVAL_MS); + if (interval.unref) { + interval.unref(); + } +} + +function stop(): void { + if (!interval) { + return; + } + clearInterval(interval); + interval = null; + logger.info('[MemDiag] Stopped memory diagnostics'); +} + +export const memoryDiagnostics = { start, stop, forceGC, getSnapshots, collectSnapshot }; diff --git a/packages/api/src/utils/tracing.ts b/packages/api/src/utils/tracing.ts new file mode 100644 index 0000000000..6a82caf092 --- /dev/null +++ b/packages/api/src/utils/tracing.ts @@ -0,0 +1,31 @@ +import { logger } from '@librechat/data-schemas'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { isEnabled } from '~/utils/common'; + +/** @see https://github.com/langchain-ai/langchainjs — @langchain/core RunTree ALS */ +const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage'); + +let warnedMissing = false; + +/** + * Runs `fn` outside the LangGraph/LangSmith tracing AsyncLocalStorage context + * so I/O handles (child processes, sockets, timers) created during `fn` + * do not permanently retain the RunTree → graph config → message data chain. + * + * Relies on the private symbol `ls:tracing_async_local_storage` from `@langchain/core`. + * If the symbol is absent, falls back to calling `fn()` directly. + */ +export function runOutsideTracing(fn: () => T): T { + const storage = (globalThis as typeof globalThis & Record>)[ + TRACING_ALS_KEY + ]; + if (!storage && !warnedMissing && isEnabled(process.env.LANGCHAIN_TRACING_V2)) { + warnedMissing = true; + logger.warn( + '[runOutsideTracing] LANGCHAIN_TRACING_V2 is set but ALS not found — ' + + 'runOutsideTracing will be a no-op. ' + + 'Verify @langchain/core version still uses Symbol.for("ls:tracing_async_local_storage").', + ); + } + return storage ? storage.run(undefined as unknown, fn) : fn(); +} From e978a934fc5ee9b84c46545aef1b9b18159d9f7b Mon Sep 17 00:00:00 2001 From: Vamsi Konakanchi <39833739+vmskonakanchi@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:51:19 +0530 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=93=8D=20feat:=20Preserve=20Deep=20Li?= =?UTF-8?q?nk=20Destinations=20Through=20the=20Auth=20Redirect=20Flow=20(#?= =?UTF-8?q?10275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added support for url query param persistance * refactor: authentication redirect handling - Introduced utility functions for managing login redirects, including `persistRedirectToSession`, `buildLoginRedirectUrl`, and `getPostLoginRedirect`. - Updated `Login` and `AuthContextProvider` components to utilize these utilities for improved redirect logic. - Refactored `useAuthRedirect` to streamline navigation to the login page while preserving intended destinations. - Cleaned up the `StartupLayout` to remove unnecessary redirect handling, ensuring a more straightforward navigation flow. - Added a new `redirect.ts` file to encapsulate redirect-related logic, enhancing code organization and maintainability. * fix: enhance safe redirect validation logic - Updated the `isSafeRedirect` function to improve validation of redirect URLs. - Ensured that only safe relative paths are accepted, specifically excluding paths that lead to the login page. - Refactored the logic to streamline the checks for valid redirect targets. * test: add unit tests for redirect utility functions - Introduced comprehensive tests for `isSafeRedirect`, `buildLoginRedirectUrl`, `getPostLoginRedirect`, and `persistRedirectToSession` functions. - Validated various scenarios including safe and unsafe redirects, URL encoding, and session storage behavior. - Enhanced test coverage to ensure robust handling of redirect logic and prevent potential security issues. * chore: streamline authentication and redirect handling - Removed unused `useLocation` import from `AuthContextProvider` and replaced its usage with `window.location` for better clarity. - Updated `StartupLayout` to check for pending redirects before navigating to the new chat page, ensuring users are directed appropriately based on their session state. - Enhanced unit tests for `useAuthRedirect` to verify correct handling of redirect parameters, including encoding of the current path and query parameters. * test: add unit tests for StartupLayout redirect behavior - Introduced a new test suite for the StartupLayout component to validate redirect logic based on authentication status and session storage. - Implemented tests to ensure correct navigation to the new conversation page when authenticated without pending redirects, and to prevent navigation when a redirect URL parameter or session storage redirect is present. - Enhanced coverage for scenarios where users are not authenticated, ensuring robust handling of redirect conditions. --------- Co-authored-by: Vamsi Konakanchi Co-authored-by: Danny Avila --- client/src/components/Auth/Login.tsx | 26 ++- client/src/hooks/AuthContext.tsx | 54 ++--- client/src/routes/Layouts/Startup.tsx | 10 +- .../routes/__tests__/StartupLayout.spec.tsx | 128 ++++++++++++ .../routes/__tests__/useAuthRedirect.spec.tsx | 80 ++++++- client/src/routes/useAuthRedirect.ts | 19 +- client/src/utils/__tests__/redirect.test.ts | 197 ++++++++++++++++++ client/src/utils/index.ts | 1 + client/src/utils/redirect.ts | 58 ++++++ 9 files changed, 529 insertions(+), 44 deletions(-) create mode 100644 client/src/routes/__tests__/StartupLayout.spec.tsx create mode 100644 client/src/utils/__tests__/redirect.test.ts create mode 100644 client/src/utils/redirect.ts diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 48a506879f..e0bf89bacd 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,15 +1,19 @@ import { useEffect, useState } from 'react'; import { ErrorTypes, registerPage } from 'librechat-data-provider'; import { OpenIDIcon, useToastContext } from '@librechat/client'; -import { useOutletContext, useSearchParams } from 'react-router-dom'; +import { useOutletContext, useSearchParams, useLocation } from 'react-router-dom'; import type { TLoginLayoutContext } from '~/common'; +import { getLoginError, persistRedirectToSession } from '~/utils'; import { ErrorMessage } from '~/components/Auth/ErrorMessage'; import SocialButton from '~/components/Auth/SocialButton'; import { useAuthContext } from '~/hooks/AuthContext'; -import { getLoginError } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; +interface LoginLocationState { + redirect_to?: string; +} + function Login() { const localize = useLocalize(); const { showToast } = useToastContext(); @@ -17,13 +21,22 @@ function Login() { const { startupConfig } = useOutletContext(); const [searchParams, setSearchParams] = useSearchParams(); - // Determine if auto-redirect should be disabled based on the URL parameter + const location = useLocation(); const disableAutoRedirect = searchParams.get('redirect') === 'false'; - // Persist the disable flag locally so that once detected, auto-redirect stays disabled. const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect); useEffect(() => { + const redirectTo = searchParams.get('redirect_to'); + if (redirectTo) { + persistRedirectToSession(decodeURIComponent(redirectTo)); + } else { + const state = location.state as LoginLocationState | null; + if (state?.redirect_to) { + persistRedirectToSession(state.redirect_to); + } + } + const oauthError = searchParams?.get('error'); if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) { showToast({ @@ -34,9 +47,8 @@ function Login() { newParams.delete('error'); setSearchParams(newParams, { replace: true }); } - }, [searchParams, setSearchParams, showToast, localize]); + }, [searchParams, setSearchParams, showToast, localize, location.state]); - // Once the disable flag is detected, update local state and remove the parameter from the URL. useEffect(() => { if (disableAutoRedirect) { setIsAutoRedirectDisabled(true); @@ -46,7 +58,6 @@ function Login() { } }, [disableAutoRedirect, searchParams, setSearchParams]); - // Determine whether we should auto-redirect to OpenID. const shouldAutoRedirect = startupConfig?.openidLoginEnabled && startupConfig?.openidAutoRedirect && @@ -60,7 +71,6 @@ function Login() { } }, [shouldAutoRedirect, startupConfig]); - // Render fallback UI if auto-redirect is active. if (shouldAutoRedirect) { return (
diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index d9d583783a..04bc3445c9 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -3,7 +3,6 @@ import { useMemo, useState, useEffect, - ReactNode, useContext, useCallback, createContext, @@ -12,6 +11,7 @@ import { debounce } from 'lodash'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { setTokenHeader, SystemRoles } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import type * as t from 'librechat-data-provider'; import { useGetRole, @@ -20,6 +20,7 @@ import { useLogoutUserMutation, useRefreshTokenMutation, } from '~/data-provider'; +import { isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils'; import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common'; import useTimeout from './useTimeout'; import store from '~/store'; @@ -58,20 +59,22 @@ const AuthContextProvider = ({ setTokenHeader(token); setIsAuthenticated(isAuthenticated); - // Use a custom redirect if set - const finalRedirect = logoutRedirectRef.current || redirect; - // Clear the stored redirect + const searchParams = new URLSearchParams(window.location.search); + const postLoginRedirect = getPostLoginRedirect(searchParams); + + const logoutRedirect = logoutRedirectRef.current; logoutRedirectRef.current = undefined; + const finalRedirect = + logoutRedirect ?? + postLoginRedirect ?? + (redirect && isSafeRedirect(redirect) ? redirect : null); + if (finalRedirect == null) { return; } - if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) { - window.location.href = finalRedirect; - } else { - navigate(finalRedirect, { replace: true }); - } + navigate(finalRedirect, { replace: true }); }, 50), [navigate, setUser], ); @@ -81,7 +84,6 @@ const AuthContextProvider = ({ onSuccess: (data: t.TLoginResponse) => { const { user, token, twoFAPending, tempToken } = data; if (twoFAPending) { - // Redirect to the two-factor authentication route. navigate(`/login/2fa?tempToken=${tempToken}`, { replace: true }); return; } @@ -91,7 +93,9 @@ const AuthContextProvider = ({ onError: (error: TResError | unknown) => { const resError = error as TResError; doSetError(resError.message); - navigate('/login', { replace: true }); + const redirectTo = new URLSearchParams(window.location.search).get('redirect_to'); + const loginPath = redirectTo ? `/login?redirect_to=${redirectTo}` : '/login'; + navigate(loginPath, { replace: true }); }, }); const logoutUser = useLogoutUserMutation({ @@ -141,30 +145,30 @@ const AuthContextProvider = ({ const { user, token = '' } = data ?? {}; if (token) { setUserContext({ token, isAuthenticated: true, user }); - } else { - console.log('Token is not present. User is not authenticated.'); - if (authConfig?.test === true) { - return; - } - navigate('/login'); + return; } + console.log('Token is not present. User is not authenticated.'); + if (authConfig?.test === true) { + return; + } + navigate(buildLoginRedirectUrl()); }, onError: (error) => { console.log('refreshToken mutation error:', error); if (authConfig?.test === true) { return; } - navigate('/login'); + navigate(buildLoginRedirectUrl()); }, }); - }, []); + }, [authConfig?.test, refreshToken, setUserContext, navigate]); useEffect(() => { if (userQuery.data) { setUser(userQuery.data); } else if (userQuery.isError) { doSetError((userQuery.error as Error).message); - navigate('/login', { replace: true }); + navigate(buildLoginRedirectUrl(), { replace: true }); } if (error != null && error && isAuthenticated) { doSetError(undefined); @@ -186,24 +190,22 @@ const AuthContextProvider = ({ ]); useEffect(() => { - const handleTokenUpdate = (event) => { + const handleTokenUpdate = (event: CustomEvent) => { console.log('tokenUpdated event received event'); - const newToken = event.detail; setUserContext({ - token: newToken, + token: event.detail, isAuthenticated: true, user: user, }); }; - window.addEventListener('tokenUpdated', handleTokenUpdate); + window.addEventListener('tokenUpdated', handleTokenUpdate as EventListener); return () => { - window.removeEventListener('tokenUpdated', handleTokenUpdate); + window.removeEventListener('tokenUpdated', handleTokenUpdate as EventListener); }; }, [setUserContext, user]); - // Make the provider update only when it should const memoedValue = useMemo( () => ({ user, diff --git a/client/src/routes/Layouts/Startup.tsx b/client/src/routes/Layouts/Startup.tsx index 9c9e0952dd..bb0e5ef254 100644 --- a/client/src/routes/Layouts/Startup.tsx +++ b/client/src/routes/Layouts/Startup.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import type { TStartupConfig } from 'librechat-data-provider'; +import { TranslationKeys, useLocalize } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; import AuthLayout from '~/components/Auth/AuthLayout'; -import { TranslationKeys, useLocalize } from '~/hooks'; +import { REDIRECT_PARAM, SESSION_KEY } from '~/utils'; const headerMap: Record = { '/login': 'com_auth_welcome_back', @@ -30,7 +31,12 @@ export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: b useEffect(() => { if (isAuthenticated) { - navigate('/c/new', { replace: true }); + const hasPendingRedirect = + new URLSearchParams(window.location.search).has(REDIRECT_PARAM) || + sessionStorage.getItem(SESSION_KEY) != null; + if (!hasPendingRedirect) { + navigate('/c/new', { replace: true }); + } } if (data) { setStartupConfig(data); diff --git a/client/src/routes/__tests__/StartupLayout.spec.tsx b/client/src/routes/__tests__/StartupLayout.spec.tsx new file mode 100644 index 0000000000..8d2c183137 --- /dev/null +++ b/client/src/routes/__tests__/StartupLayout.spec.tsx @@ -0,0 +1,128 @@ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { SESSION_KEY } from '~/utils'; +import StartupLayout from '../Layouts/Startup'; + +if (typeof Request === 'undefined') { + global.Request = class Request { + constructor( + public url: string, + public init?: RequestInit, + ) {} + } as any; +} + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: jest.fn(() => ({ + data: null, + isFetching: false, + error: null, + })), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: jest.fn(() => (key: string) => key), + TranslationKeys: {}, +})); + +jest.mock('~/components/Auth/AuthLayout', () => { + return function MockAuthLayout({ children }: { children: React.ReactNode }) { + return
{children}
; + }; +}); + +function ChildRoute() { + return
Child
; +} + +function NewConversation() { + return
New Conversation
; +} + +const createTestRouter = (initialEntry: string, isAuthenticated: boolean) => + createMemoryRouter( + [ + { + path: '/login', + element: , + children: [{ index: true, element: }], + }, + { + path: '/c/new', + element: , + }, + ], + { initialEntries: [initialEntry] }, + ); + +describe('StartupLayout — redirect race condition', () => { + const originalLocation = window.location; + + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }); + jest.restoreAllMocks(); + }); + + it('navigates to /c/new when authenticated with no pending redirect', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + + const router = createTestRouter('/login', true); + render(); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/c/new'); + }); + }); + + it('does NOT navigate to /c/new when redirect_to URL param is present', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '?redirect_to=%2Fc%2Fabc123' }, + writable: true, + }); + + const router = createTestRouter('/login?redirect_to=%2Fc%2Fabc123', true); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); + + it('does NOT navigate to /c/new when sessionStorage redirect is present', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + + const router = createTestRouter('/login', true); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); + + it('does NOT navigate when not authenticated', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + + const router = createTestRouter('/login', false); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); +}); diff --git a/client/src/routes/__tests__/useAuthRedirect.spec.tsx b/client/src/routes/__tests__/useAuthRedirect.spec.tsx index 19226aa29f..2f3a47c022 100644 --- a/client/src/routes/__tests__/useAuthRedirect.spec.tsx +++ b/client/src/routes/__tests__/useAuthRedirect.spec.tsx @@ -33,9 +33,8 @@ function TestComponent() { * Creates a test router with optional basename to verify navigation works correctly * with subdirectory deployments (e.g., /librechat) */ -const createTestRouter = (basename = '/') => { - // When using basename, initialEntries must include the basename - const initialEntry = basename === '/' ? '/' : `${basename}/`; +const createTestRouter = (basename = '/', initialEntry?: string) => { + const defaultEntry = basename === '/' ? '/' : `${basename}/`; return createMemoryRouter( [ @@ -47,10 +46,14 @@ const createTestRouter = (basename = '/') => { path: '/login', element:
Login Page
, }, + { + path: '/c/:id', + element: , + }, ], { basename, - initialEntries: [initialEntry], + initialEntries: [initialEntry ?? defaultEntry], }, ); }; @@ -199,4 +202,73 @@ describe('useAuthRedirect', () => { expect(testResult.isAuthenticated).toBe(true); }); }); + + it('should include redirect_to param with encoded current path when redirecting', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter('/', '/c/abc123'); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + const search = router.state.location.search; + const params = new URLSearchParams(search); + const redirectTo = params.get('redirect_to'); + expect(redirectTo).not.toBeNull(); + expect(decodeURIComponent(redirectTo!)).toBe('/c/abc123'); + }, + { timeout: 1000 }, + ); + }); + + it('should encode query params and hash from the source URL', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter('/', '/c/abc123?q=hello&submit=true#section'); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + const params = new URLSearchParams(router.state.location.search); + const decoded = decodeURIComponent(params.get('redirect_to')!); + expect(decoded).toBe('/c/abc123?q=hello&submit=true#section'); + }, + { timeout: 1000 }, + ); + }); + + it('should not append redirect_to when already on /login', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createMemoryRouter( + [ + { + path: '/login', + element: , + }, + ], + { initialEntries: ['/login'] }, + ); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + }, + { timeout: 1000 }, + ); + + expect(router.state.location.search).toBe(''); + }); }); diff --git a/client/src/routes/useAuthRedirect.ts b/client/src/routes/useAuthRedirect.ts index 86d8103384..7303952155 100644 --- a/client/src/routes/useAuthRedirect.ts +++ b/client/src/routes/useAuthRedirect.ts @@ -1,22 +1,33 @@ import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { buildLoginRedirectUrl } from '~/utils'; import { useAuthContext } from '~/hooks'; export default function useAuthRedirect() { const { user, roles, isAuthenticated } = useAuthContext(); const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { const timeout = setTimeout(() => { - if (!isAuthenticated) { - navigate('/login', { replace: true }); + if (isAuthenticated) { + return; } + + if (location.pathname.startsWith('/login')) { + navigate('/login', { replace: true }); + return; + } + + navigate(buildLoginRedirectUrl(location.pathname, location.search, location.hash), { + replace: true, + }); }, 300); return () => { clearTimeout(timeout); }; - }, [isAuthenticated, navigate]); + }, [isAuthenticated, navigate, location]); return { user, diff --git a/client/src/utils/__tests__/redirect.test.ts b/client/src/utils/__tests__/redirect.test.ts new file mode 100644 index 0000000000..36336b0d94 --- /dev/null +++ b/client/src/utils/__tests__/redirect.test.ts @@ -0,0 +1,197 @@ +import { + isSafeRedirect, + buildLoginRedirectUrl, + getPostLoginRedirect, + persistRedirectToSession, + SESSION_KEY, +} from '../redirect'; + +describe('isSafeRedirect', () => { + it('accepts a simple relative path', () => { + expect(isSafeRedirect('/c/new')).toBe(true); + }); + + it('accepts a path with query params and hash', () => { + expect(isSafeRedirect('/c/new?q=hello&submit=true#section')).toBe(true); + }); + + it('accepts a nested path', () => { + expect(isSafeRedirect('/dashboard/settings/profile')).toBe(true); + }); + + it('rejects an absolute http URL', () => { + expect(isSafeRedirect('https://evil.com')).toBe(false); + }); + + it('rejects an absolute http URL with path', () => { + expect(isSafeRedirect('https://evil.com/phishing')).toBe(false); + }); + + it('rejects a protocol-relative URL', () => { + expect(isSafeRedirect('//evil.com')).toBe(false); + }); + + it('rejects a bare domain', () => { + expect(isSafeRedirect('evil.com')).toBe(false); + }); + + it('rejects an empty string', () => { + expect(isSafeRedirect('')).toBe(false); + }); + + it('rejects /login to prevent redirect loops', () => { + expect(isSafeRedirect('/login')).toBe(false); + }); + + it('rejects /login with query params', () => { + expect(isSafeRedirect('/login?redirect_to=/c/new')).toBe(false); + }); + + it('rejects /login sub-paths', () => { + expect(isSafeRedirect('/login/2fa')).toBe(false); + }); + + it('rejects /login with hash', () => { + expect(isSafeRedirect('/login#foo')).toBe(false); + }); + + it('accepts the root path', () => { + expect(isSafeRedirect('/')).toBe(true); + }); +}); + +describe('buildLoginRedirectUrl', () => { + const originalLocation = window.location; + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' }, + writable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }); + }); + + it('builds a login URL from explicit args', () => { + const result = buildLoginRedirectUrl('/c/new', '?q=hello', ''); + expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello'); + }); + + it('encodes complex paths with query and hash', () => { + const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section'); + expect(result).toContain('redirect_to='); + const encoded = result.split('redirect_to=')[1]; + expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section'); + }); + + it('falls back to window.location when no args provided', () => { + const result = buildLoginRedirectUrl(); + const encoded = result.split('redirect_to=')[1]; + expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5'); + }); + + it('falls back to "/" when all location parts are empty', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '', search: '', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login?redirect_to=%2F'); + }); +}); + +describe('getPostLoginRedirect', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('returns the redirect_to param when valid', () => { + const params = new URLSearchParams('redirect_to=%2Fc%2Fnew'); + expect(getPostLoginRedirect(params)).toBe('/c/new'); + }); + + it('falls back to sessionStorage when no URL param', () => { + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBe('/c/abc123'); + }); + + it('prefers URL param over sessionStorage', () => { + sessionStorage.setItem(SESSION_KEY, '/c/old'); + const params = new URLSearchParams('redirect_to=%2Fc%2Fnew'); + expect(getPostLoginRedirect(params)).toBe('/c/new'); + }); + + it('clears sessionStorage after reading', () => { + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + const params = new URLSearchParams(); + getPostLoginRedirect(params); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('returns null when no redirect source exists', () => { + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects an absolute URL from params', () => { + const params = new URLSearchParams('redirect_to=https%3A%2F%2Fevil.com'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects a protocol-relative URL from params', () => { + const params = new URLSearchParams('redirect_to=%2F%2Fevil.com'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects an absolute URL from sessionStorage', () => { + sessionStorage.setItem(SESSION_KEY, 'https://evil.com'); + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects /login redirect to prevent loops', () => { + const params = new URLSearchParams('redirect_to=%2Flogin'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects /login sub-path redirect', () => { + const params = new URLSearchParams('redirect_to=%2Flogin%2F2fa'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('still clears sessionStorage even when target is unsafe', () => { + sessionStorage.setItem(SESSION_KEY, 'https://evil.com'); + const params = new URLSearchParams(); + getPostLoginRedirect(params); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); +}); + +describe('persistRedirectToSession', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('stores a valid relative path', () => { + persistRedirectToSession('/c/new?q=hello'); + expect(sessionStorage.getItem(SESSION_KEY)).toBe('/c/new?q=hello'); + }); + + it('rejects an absolute URL', () => { + persistRedirectToSession('https://evil.com'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('rejects a protocol-relative URL', () => { + persistRedirectToSession('//evil.com'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('rejects /login paths', () => { + persistRedirectToSession('/login?redirect_to=/c/new'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index b8117b2677..6f081c7300 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -13,6 +13,7 @@ export * from './agents'; export * from './drafts'; export * from './convos'; export * from './routes'; +export * from './redirect'; export * from './presets'; export * from './prompts'; export * from './textarea'; diff --git a/client/src/utils/redirect.ts b/client/src/utils/redirect.ts new file mode 100644 index 0000000000..d2b7588151 --- /dev/null +++ b/client/src/utils/redirect.ts @@ -0,0 +1,58 @@ +const REDIRECT_PARAM = 'redirect_to'; +const SESSION_KEY = 'post_login_redirect_to'; + +/** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */ +function isSafeRedirect(url: string): boolean { + if (!url.startsWith('/') || url.startsWith('//')) { + return false; + } + const path = url.split('?')[0].split('#')[0]; + return !path.startsWith('/login'); +} + +/** Builds a `/login?redirect_to=...` URL, reading from window.location when no args are provided */ +function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string { + const p = pathname ?? window.location.pathname; + const s = search ?? window.location.search; + const h = hash ?? window.location.hash; + const currentPath = `${p}${s}${h}`; + const encoded = encodeURIComponent(currentPath || '/'); + return `/login?${REDIRECT_PARAM}=${encoded}`; +} + +/** + * Resolves the post-login redirect from URL params and sessionStorage, + * cleans up both sources, and returns the validated target (or null). + */ +function getPostLoginRedirect(searchParams: URLSearchParams): string | null { + const encoded = searchParams.get(REDIRECT_PARAM); + const urlRedirect = encoded ? decodeURIComponent(encoded) : null; + const storedRedirect = sessionStorage.getItem(SESSION_KEY); + + const target = urlRedirect ?? storedRedirect; + + if (storedRedirect) { + sessionStorage.removeItem(SESSION_KEY); + } + + if (target == null || !isSafeRedirect(target)) { + return null; + } + + return target; +} + +function persistRedirectToSession(value: string): void { + if (isSafeRedirect(value)) { + sessionStorage.setItem(SESSION_KEY, value); + } +} + +export { + SESSION_KEY, + REDIRECT_PARAM, + isSafeRedirect, + persistRedirectToSession, + buildLoginRedirectUrl, + getPostLoginRedirect, +}; From 13df8ed67c45438ba1da7b8000619c433c6e8d94 Mon Sep 17 00:00:00 2001 From: Juri Kuehn Date: Thu, 26 Feb 2026 04:31:03 +0100 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Add=20OPENID=5FEMAIL?= =?UTF-8?q?=5FCLAIM=20for=20Configurable=20OpenID=20User=20Identifier=20(#?= =?UTF-8?q?11699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow setting the claim field to be used when OpenID login is configured * fix(openid): harden getOpenIdEmail and expand test coverage Guard against non-string claim values in getOpenIdEmail to prevent a TypeError crash in isEmailDomainAllowed when domain restrictions are configured. Improve warning messages to name the fallback chain explicitly and distinguish missing vs. non-string claim values. Fix the domain-block error log to record the resolved identifier rather than userinfo.email, which was misleading when OPENID_EMAIL_CLAIM resolved to a different field (e.g. upn). Fix a latent test defect in openIdJwtStrategy.spec.js where the ~/server/services/Config mock exported getCustomConfig instead of getAppConfig, the symbol actually consumed by openidStrategy.js. Add refreshController tests covering the OPENID_EMAIL_CLAIM paths, which were previously untested despite being a stated fix target. Expand JWT strategy tests with null-payload, empty/whitespace OPENID_EMAIL_CLAIM, migration-via-preferred_username, and call-order assertions for the findUser lookup sequence. * test(auth): enhance AuthController and openIdJwtStrategy tests for openidId updates Added a new test in AuthController to verify that the openidId is updated correctly when a migration is triggered during the refresh process. Expanded the openIdJwtStrategy tests to include assertions for the updateUser function, ensuring that the correct parameters are passed when a user is found with a legacy email. This improves test coverage for OpenID-related functionality. --------- Co-authored-by: Danny Avila --- .env.example | 3 + api/server/controllers/AuthController.js | 4 +- api/server/controllers/AuthController.spec.js | 166 +++++++++++++++++- api/strategies/index.js | 3 +- api/strategies/openIdJwtStrategy.js | 3 +- api/strategies/openIdJwtStrategy.spec.js | 166 +++++++++++++++++- api/strategies/openidStrategy.js | 34 +++- api/strategies/openidStrategy.spec.js | 81 ++++++++- 8 files changed, 447 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3e94a0c63a..e19346c4bf 100644 --- a/.env.example +++ b/.env.example @@ -513,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND= OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name OPENID_NAME_CLAIM= +# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID) +# When not set, defaults to: email -> preferred_username -> upn +OPENID_EMAIL_CLAIM= # Optional audience parameter for OpenID authorization requests OPENID_AUDIENCE= diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 58d2427512..13d024cd03 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -18,7 +18,7 @@ const { findUser, } = require('~/models'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { getOpenIdConfig } = require('~/strategies'); +const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const registrationController = async (req, res) => { try { @@ -87,7 +87,7 @@ const refreshController = async (req, res) => { const claims = tokenset.claims(); const { user, error, migration } = await findOpenIDUser({ findUser, - email: claims.email, + email: getOpenIdEmail(claims), openidId: claims.sub, idOnTheSource: claims.oid, strategyName: 'refreshController', diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index cbf72657fb..fef670baa8 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -1,5 +1,5 @@ jest.mock('@librechat/data-schemas', () => ({ - logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() }, + logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() }, })); jest.mock('~/server/services/GraphTokenService', () => ({ getGraphApiToken: jest.fn(), @@ -11,7 +11,8 @@ jest.mock('~/server/services/AuthService', () => ({ setAuthTokens: jest.fn(), registerUser: jest.fn(), })); -jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() })); +jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() })); +jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() })); jest.mock('~/models', () => ({ deleteAllUserSessions: jest.fn(), getUserById: jest.fn(), @@ -24,9 +25,13 @@ jest.mock('@librechat/api', () => ({ findOpenIDUser: jest.fn(), })); -const { isEnabled } = require('@librechat/api'); +const openIdClient = require('openid-client'); +const { isEnabled, findOpenIDUser } = require('@librechat/api'); +const { graphTokenController, refreshController } = require('./AuthController'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { graphTokenController } = require('./AuthController'); +const { setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const { updateUser } = require('~/models'); describe('graphTokenController', () => { let req, res; @@ -142,3 +147,156 @@ describe('graphTokenController', () => { }); }); }); + +describe('refreshController – OpenID path', () => { + const mockTokenset = { + claims: jest.fn(), + access_token: 'new-access', + id_token: 'new-id', + refresh_token: 'new-refresh', + }; + + const baseClaims = { + sub: 'oidc-sub-123', + oid: 'oid-456', + email: 'user@example.com', + exp: 9999999999, + }; + + let req, res; + + beforeEach(() => { + jest.clearAllMocks(); + + isEnabled.mockReturnValue(true); + getOpenIdConfig.mockReturnValue({ some: 'config' }); + openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset); + mockTokenset.claims.mockReturnValue(baseClaims); + getOpenIdEmail.mockReturnValue(baseClaims.email); + setOpenIDAuthTokens.mockReturnValue('new-app-token'); + updateUser.mockResolvedValue({}); + + req = { + headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' }, + session: {}, + }; + + res = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + redirect: jest.fn(), + }; + }); + + it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => { + const user = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims); + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: baseClaims.email }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => { + const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' }; + mockTokenset.claims.mockReturnValue(claimsWithUpn); + getOpenIdEmail.mockReturnValue('user@corp.example.com'); + + const user = { + _id: 'user-db-id', + email: 'user@corp.example.com', + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn); + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@corp.example.com' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should fall back to claims.email when configured claim is absent from token claims', async () => { + getOpenIdEmail.mockReturnValue(baseClaims.email); + + const user = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: baseClaims.email }), + ); + }); + + it('should update openidId when migration is triggered on refresh', async () => { + const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: true }); + + await refreshController(req, res); + + expect(updateUser).toHaveBeenCalledWith( + 'user-db-id', + expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => { + findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false }); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.redirect).toHaveBeenCalledWith('/login'); + }); + + it('should return 401 and redirect when findOpenIDUser returns an error', async () => { + findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false }); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.redirect).toHaveBeenCalledWith('/login'); + }); + + it('should skip OpenID path when token_provider is not openid', async () => { + req.headers.cookie = 'token_provider=local; refreshToken=some-token'; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); + }); + + it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => { + isEnabled.mockReturnValue(false); + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); + }); + + it('should return 200 with token not provided when refresh token is absent', async () => { + req.headers.cookie = 'token_provider=openid'; + req.session = {}; + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith('Refresh token not provided'); + }); +}); diff --git a/api/strategies/index.js b/api/strategies/index.js index b4f7bd3cac..9a1c58ad38 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,4 +1,4 @@ -const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); +const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy'); const openIdJwtLogin = require('./openIdJwtStrategy'); const facebookLogin = require('./facebookStrategy'); const discordLogin = require('./discordStrategy'); @@ -20,6 +20,7 @@ module.exports = { facebookLogin, setupOpenId, getOpenIdConfig, + getOpenIdEmail, ldapLogin, setupSaml, openIdJwtLogin, diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 997dcec397..ececf8df54 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); +const { getOpenIdEmail } = require('./openidStrategy'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { updateUser, findUser } = require('~/models'); @@ -53,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => { const { user, error, migration } = await findOpenIDUser({ findUser, - email: payload?.email, + email: payload ? getOpenIdEmail(payload) : undefined, openidId: payload?.sub, idOnTheSource: payload?.oid, strategyName: 'openIdJwtLogin', diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 566afe5a90..79af848046 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -29,10 +29,21 @@ jest.mock('~/models', () => ({ findUser: jest.fn(), updateUser: jest.fn(), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({ + saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), + })), +})); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({}), +})); +jest.mock('~/cache/getLogStores', () => + jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }), +); const { findOpenIDUser } = require('@librechat/api'); -const { updateUser } = require('~/models'); const openIdJwtLogin = require('./openIdJwtStrategy'); +const { findUser, updateUser } = require('~/models'); // Helper: build a mock openIdConfig const mockOpenIdConfig = { @@ -181,3 +192,156 @@ describe('openIdJwtStrategy – token source handling', () => { expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token); }); }); + +describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { + const payload = { + sub: 'oidc-123', + email: 'test@example.com', + preferred_username: 'testuser', + upn: 'test@corp.example.com', + exp: 9999999999, + }; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.OPENID_EMAIL_CLAIM; + + // Use real findOpenIDUser so it delegates to the findUser mock + const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser; + findOpenIDUser.mockImplementation(realFindOpenIDUser); + + findUser.mockResolvedValue(null); + updateUser.mockResolvedValue({}); + + openIdJwtLogin(mockOpenIdConfig); + }); + + afterEach(() => { + delete process.env.OPENID_EMAIL_CLAIM; + }); + + it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { + const existingUser = { + _id: 'user-id-1', + provider: 'openid', + openidId: payload.sub, + email: payload.email, + role: SystemRoles.USER, + }; + findUser.mockImplementation(async (query) => { + if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) { + return existingUser; + } + return null; + }); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith( + expect.objectContaining({ + $or: expect.arrayContaining([{ openidId: payload.sub }]), + }), + ); + }); + + it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => { + process.env.OPENID_EMAIL_CLAIM = 'upn'; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledTimes(2); + expect(findUser.mock.calls[0][0]).toMatchObject({ + $or: expect.arrayContaining([{ openidId: payload.sub }]), + }); + expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' }); + expect(user).toBe(false); + }); + + it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => { + process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + expect(user).toBe(false); + }); + + it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => { + process.env.OPENID_EMAIL_CLAIM = ' upn '; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' }); + }); + + it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ''; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + }); + + it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ' '; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + }); + + it('should resolve undefined email when payload is null', async () => { + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, null); + + expect(user).toBe(false); + }); + + it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => { + const payloadNoEmail = { + sub: 'oidc-new-sub', + preferred_username: 'legacy@corp.com', + upn: 'legacy@corp.com', + exp: 9999999999, + }; + + const legacyUser = { + _id: 'legacy-db-id', + email: 'legacy@corp.com', + openidId: null, + role: SystemRoles.USER, + }; + + findUser.mockImplementation(async (query) => { + if (query.$or) { + return null; + } + if (query.email === 'legacy@corp.com') { + return legacyUser; + } + return null; + }); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payloadNoEmail); + + expect(findUser).toHaveBeenCalledTimes(2); + expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' }); + expect(user).toBeTruthy(); + expect(updateUser).toHaveBeenCalledWith( + 'legacy-db-id', + expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }), + ); + }); +}); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 15e21f67ef..0ebdcb04e1 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -267,6 +267,34 @@ function getFullName(userinfo) { return userinfo.username || userinfo.email; } +/** + * Resolves the user identifier from OpenID claims. + * Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn. + * + * @param {Object} userinfo - The user information object from OpenID Connect + * @returns {string|undefined} The resolved identifier string + */ +function getOpenIdEmail(userinfo) { + const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim(); + if (claimKey) { + const value = userinfo[claimKey]; + if (typeof value === 'string' && value) { + return value; + } + if (value !== undefined && value !== null) { + logger.warn( + `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`, + ); + } else { + logger.warn( + `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`, + ); + } + } + const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn; + return typeof fallback === 'string' ? fallback : undefined; +} + /** * Converts an input into a string suitable for a username. * If the input is a string, it will be returned as is. @@ -379,11 +407,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } const appConfig = await getAppConfig(); - /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ - const email = userinfo.email || userinfo.preferred_username || userinfo.upn; + const email = getOpenIdEmail(userinfo); if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error( - `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, + `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, ); throw new Error('Email domain not allowed'); } @@ -728,4 +755,5 @@ function getOpenIdConfig() { module.exports = { setupOpenId, getOpenIdConfig, + getOpenIdEmail, }; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 00c65106ad..485b77829e 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1,6 +1,6 @@ +const undici = require('undici'); const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); -const undici = require('undici'); const { ErrorTypes } = require('librechat-data-provider'); const { findUser, createUser, updateUser } = require('~/models'); const { setupOpenId } = require('./openidStrategy'); @@ -152,6 +152,7 @@ describe('setupOpenId', () => { process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; + delete process.env.OPENID_EMAIL_CLAIM; delete process.env.PROXY; delete process.env.OPENID_USE_PKCE; @@ -1402,4 +1403,82 @@ describe('setupOpenId', () => { expect(user).toBe(false); }); }); + + describe('OPENID_EMAIL_CLAIM', () => { + it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { + const { user } = await validate(tokenset); + expect(user.email).toBe('test@example.com'); + }); + + it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => { + process.env.OPENID_EMAIL_CLAIM = 'upn'; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@corp.example.com' }), + expect.anything(), + true, + true, + ); + }); + + it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims() }; + delete userinfo.email; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('testusername'); + }); + + it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + delete userinfo.email; + delete userinfo.preferred_username; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ''; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => { + process.env.OPENID_EMAIL_CLAIM = ' upn '; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ' '; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'), + ); + }); + }); }); From 3a079b980af9be3c0b3d164c6175c8a544360d73 Mon Sep 17 00:00:00 2001 From: marbence101 <72440997+marbence101@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:16:45 +0100 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=93=8C=20fix:=20Populate=20userMessag?= =?UTF-8?q?e.files=20Before=20First=20DB=20Save=20(#11939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: populate userMessage.files before first DB save * fix: ESLint error fixed * fix: deduplicate file-population logic and add test coverage Extract `buildMessageFiles` helper into `packages/api/src/utils/message` to replace three near-identical loops in BaseClient and both agent controllers. Fixes set poisoning from undefined file_id entries, moves file population inside the skipSaveUserMessage guard to avoid wasted work, and adds full unit test coverage for the new behavior. * chore: reorder import statements in openIdJwtStrategy.js for consistency --------- Co-authored-by: Danny Avila --- api/app/clients/BaseClient.js | 9 ++ api/app/clients/specs/BaseClient.test.js | 119 +++++++++++++++++++++ api/server/controllers/agents/request.js | 25 ++--- api/strategies/openIdJwtStrategy.js | 2 +- packages/api/src/utils/message.spec.ts | 127 +++++++++++++++++------ packages/api/src/utils/message.ts | 24 +++++ 6 files changed, 258 insertions(+), 48 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index fab82db93b..e5771aac55 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas'); const { countTokens, getBalanceConfig, + buildMessageFiles, extractFileContext, encodeAndFormatAudios, encodeAndFormatVideos, @@ -670,6 +671,14 @@ class BaseClient { } if (!isEdited && !this.skipSaveUserMessage) { + const reqFiles = this.options.req?.body?.files; + if (reqFiles && Array.isArray(this.options.attachments)) { + const files = buildMessageFiles(reqFiles, this.options.attachments); + if (files.length > 0) { + userMessage.files = files; + } + delete userMessage.image_urls; + } userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user); this.savedMessageIds.add(userMessage.messageId); if (typeof opts?.getReqData === 'function') { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index fed80de28c..15328af644 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -928,4 +928,123 @@ describe('BaseClient', () => { expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label) }); }); + + describe('sendMessage file population', () => { + const attachment = { + file_id: 'file-abc', + filename: 'image.png', + filepath: '/uploads/image.png', + type: 'image/png', + bytes: 1024, + object: 'file', + user: 'user-1', + embedded: false, + usage: 0, + text: 'large ocr blob that should be stripped', + _id: 'mongo-id-1', + }; + + beforeEach(() => { + TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } }; + TestClient.options.attachments = [attachment]; + }); + + test('populates userMessage.files before saveMessageToDatabase is called', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => { + return Promise.resolve({ message: msg }); + }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave).toBeDefined(); + expect(userSave[0].files).toBeDefined(); + expect(userSave[0].files).toHaveLength(1); + expect(userSave[0].files[0].file_id).toBe('file-abc'); + }); + + test('strips text and _id from files before saving', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files[0].text).toBeUndefined(); + expect(userSave[0].files[0]._id).toBeUndefined(); + expect(userSave[0].files[0].filename).toBe('image.png'); + }); + + test('deletes image_urls from userMessage when files are present', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + TestClient.options.attachments = [ + { ...attachment, image_urls: ['data:image/png;base64,...'] }, + ]; + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].image_urls).toBeUndefined(); + }); + + test('does not set files when no attachments match request file IDs', async () => { + TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } }; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toBeUndefined(); + }); + + test('skips file population when attachments is not an array (Promise case)', async () => { + TestClient.options.attachments = Promise.resolve([attachment]); + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toBeUndefined(); + }); + + test('skips file population when skipSaveUserMessage is true', async () => { + TestClient.skipSaveUserMessage = true; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg?.isCreatedByUser, + ); + expect(userSave).toBeUndefined(); + }); + + test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => { + TestClient.options.req = { + body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] }, + }; + TestClient.options.attachments = [ + { ...attachment, file_id: undefined }, + { ...attachment, file_id: 'file-abc' }, + ]; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toHaveLength(1); + expect(userSave[0].files[0].file_id).toBe('file-abc'); + }); + }); }); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 79387b6e89..dea5400036 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -3,9 +3,9 @@ const { Constants, ViolationTypes } = require('librechat-data-provider'); const { sendEvent, getViolationInfo, + buildMessageFiles, GenerationJobManager, decrementPendingRequest, - sanitizeFileForTransmit, sanitizeMessageForTransmit, checkAndIncrementPendingRequest, } = require('@librechat/api'); @@ -252,13 +252,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit conversation.title = conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - if (req.body.files && client.options?.attachments) { - userMessage.files = []; - const messageFiles = new Set(req.body.files.map((file) => file.file_id)); - for (const attachment of client.options.attachments) { - if (messageFiles.has(attachment.file_id)) { - userMessage.files.push(sanitizeFileForTransmit(attachment)); - } + if (req.body.files && Array.isArray(client.options.attachments)) { + const files = buildMessageFiles(req.body.files, client.options.attachments); + if (files.length > 0) { + userMessage.files = files; } delete userMessage.image_urls; } @@ -639,14 +636,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle conversation.title = conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - // Process files if needed (sanitize to remove large text fields before transmission) - if (req.body.files && client.options?.attachments) { - userMessage.files = []; - const messageFiles = new Set(req.body.files.map((file) => file.file_id)); - for (const attachment of client.options.attachments) { - if (messageFiles.has(attachment.file_id)) { - userMessage.files.push(sanitizeFileForTransmit(attachment)); - } + if (req.body.files && Array.isArray(client.options.attachments)) { + const files = buildMessageFiles(req.body.files, client.options.attachments); + if (files.length > 0) { + userMessage.files = files; } delete userMessage.image_urls; } diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index ececf8df54..83a40bf948 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -4,8 +4,8 @@ const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); -const { getOpenIdEmail } = require('./openidStrategy'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { getOpenIdEmail } = require('./openidStrategy'); const { updateUser, findUser } = require('~/models'); /** diff --git a/packages/api/src/utils/message.spec.ts b/packages/api/src/utils/message.spec.ts index ba626c83fd..7fe6cf5239 100644 --- a/packages/api/src/utils/message.spec.ts +++ b/packages/api/src/utils/message.spec.ts @@ -1,5 +1,10 @@ import { Constants } from 'librechat-data-provider'; -import { sanitizeFileForTransmit, sanitizeMessageForTransmit, getThreadData } from './message'; +import { + sanitizeMessageForTransmit, + sanitizeFileForTransmit, + buildMessageFiles, + getThreadData, +} from './message'; /** Cast to string for type compatibility with ThreadMessage */ const NO_PARENT = Constants.NO_PARENT as string; @@ -125,47 +130,107 @@ describe('sanitizeMessageForTransmit', () => { }); }); +describe('buildMessageFiles', () => { + const baseAttachment = { + file_id: 'file-1', + filename: 'test.png', + filepath: '/uploads/test.png', + type: 'image/png', + bytes: 512, + object: 'file' as const, + user: 'user-1', + embedded: false, + usage: 0, + text: 'big ocr text', + _id: 'mongo-id', + }; + + it('returns sanitized files matching request file IDs', () => { + const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + expect(result?.[0]).not.toHaveProperty('text'); + expect(result?.[0]).not.toHaveProperty('_id'); + }); + + it('returns undefined when no attachments match request IDs', () => { + const result = buildMessageFiles([{ file_id: 'file-nomatch' }], [baseAttachment]); + expect(result).toEqual([]); + }); + + it('returns undefined for empty attachments array', () => { + const result = buildMessageFiles([{ file_id: 'file-1' }], []); + expect(result).toEqual([]); + }); + + it('returns undefined for empty request files array', () => { + const result = buildMessageFiles([], [baseAttachment]); + expect(result).toEqual([]); + }); + + it('filters out undefined file_id entries in request files (no set poisoning)', () => { + const undefinedAttachment = { ...baseAttachment, file_id: undefined as unknown as string }; + const result = buildMessageFiles( + [{ file_id: undefined }, { file_id: 'file-1' }], + [undefinedAttachment, baseAttachment], + ); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + }); + + it('returns only attachments whose file_id is in the request set', () => { + const attachment2 = { ...baseAttachment, file_id: 'file-2', filename: 'b.png' }; + const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment, attachment2]); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + }); + + it('does not mutate original attachment objects', () => { + buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]); + expect(baseAttachment.text).toBe('big ocr text'); + expect(baseAttachment._id).toBe('mongo-id'); + }); +}); + describe('getThreadData', () => { - describe('edge cases - empty and null inputs', () => { - it('should return empty result for empty messages array', () => { - const result = getThreadData([], 'parent-123'); + it('should return empty result for empty messages array', () => { + const result = getThreadData([], 'parent-123'); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result for null parentMessageId', () => { - const messages = [ - { messageId: 'msg-1', parentMessageId: null }, - { messageId: 'msg-2', parentMessageId: 'msg-1' }, - ]; + it('should return empty result for null parentMessageId', () => { + const messages = [ + { messageId: 'msg-1', parentMessageId: null }, + { messageId: 'msg-2', parentMessageId: 'msg-1' }, + ]; - const result = getThreadData(messages, null); + const result = getThreadData(messages, null); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result for undefined parentMessageId', () => { - const messages = [{ messageId: 'msg-1', parentMessageId: null }]; + it('should return empty result for undefined parentMessageId', () => { + const messages = [{ messageId: 'msg-1', parentMessageId: null }]; - const result = getThreadData(messages, undefined); + const result = getThreadData(messages, undefined); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result when parentMessageId not found in messages', () => { - const messages = [ - { messageId: 'msg-1', parentMessageId: null }, - { messageId: 'msg-2', parentMessageId: 'msg-1' }, - ]; + it('should return empty result when parentMessageId not found in messages', () => { + const messages = [ + { messageId: 'msg-1', parentMessageId: null }, + { messageId: 'msg-2', parentMessageId: 'msg-1' }, + ]; - const result = getThreadData(messages, 'non-existent'); + const result = getThreadData(messages, 'non-existent'); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); }); describe('thread traversal', () => { diff --git a/packages/api/src/utils/message.ts b/packages/api/src/utils/message.ts index b1e939c6d7..719d04b838 100644 --- a/packages/api/src/utils/message.ts +++ b/packages/api/src/utils/message.ts @@ -1,6 +1,9 @@ import { Constants } from 'librechat-data-provider'; import type { TFile, TMessage } from 'librechat-data-provider'; +/** Minimal shape for request file entries (from `req.body.files`) */ +type RequestFile = { file_id?: string }; + /** Fields to strip from files before client transmission */ const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const; @@ -32,6 +35,27 @@ export function sanitizeFileForTransmit>( return sanitized; } +/** Filters attachments to those whose `file_id` appears in `requestFiles`, then sanitizes each. */ +export function buildMessageFiles>( + requestFiles: RequestFile[], + attachments: T[], +): Omit[] { + const requestFileIds = new Set(); + for (const f of requestFiles) { + if (f.file_id) { + requestFileIds.add(f.file_id); + } + } + + const files: Omit[] = []; + for (const attachment of attachments) { + if (attachment.file_id != null && requestFileIds.has(attachment.file_id)) { + files.push(sanitizeFileForTransmit(attachment)); + } + } + return files; +} + /** * Sanitizes a message object before transmitting to client. * Removes large fields like `fileContext` and strips `text` from embedded files. From 046e92217f5c2ad1fe1ed7708115911f85d1857c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 14:39:49 -0500 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A7=A9=20feat:=20OpenDocument=20Forma?= =?UTF-8?q?t=20File=20Upload=20and=20Native=20ODS=20Parsing=20(#11959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add support for OpenDocument MIME types in file configuration Updated the applicationMimeTypes regex to include support for OASIS OpenDocument formats, enhancing the file type recognition capabilities of the data provider. * feat: document processing with OpenDocument support Added support for OpenDocument Spreadsheet (ODS) MIME type in the file processing service and updated the document parser to handle ODS files. Included tests to verify correct parsing of ODS documents and updated file configuration to recognize OpenDocument formats. * refactor: Enhance document processing to support additional Excel MIME types Updated the document processing logic to utilize a regex for matching Excel MIME types, improving flexibility in handling various Excel file formats. Added tests to ensure correct parsing of new MIME types, including multiple Excel variants and OpenDocument formats. Adjusted file configuration to include these MIME types for better recognition in the file processing service. * feat: Add support for additional OpenDocument MIME types in file processing Enhanced the document processing service to support ODT, ODP, and ODG MIME types. Updated tests to verify correct routing through the OCR strategy for these new formats. Adjusted documentation to reflect changes in handled MIME types for improved clarity. --- api/server/services/Files/process.js | 10 +- api/server/services/Files/process.spec.js | 24 ++++ packages/api/src/files/documents/crud.spec.ts | 44 +++++++ packages/api/src/files/documents/crud.ts | 31 ++--- packages/api/src/files/documents/sample.ods | Bin 0 -> 8040 bytes .../data-provider/src/file-config.spec.ts | 113 ++++++++++++++++++ packages/data-provider/src/file-config.ts | 24 +++- 7 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 packages/api/src/files/documents/sample.ods diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index d69be6a00c..d01128927a 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -16,6 +16,7 @@ const { removeNullishValues, isAssistantsEndpoint, getEndpointFileConfig, + documentParserMimeTypes, } = require('librechat-data-provider'); const { EnvVar } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); @@ -559,19 +560,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { const fileConfig = mergeFileConfig(appConfig.fileConfig); - const documentParserMimeTypes = [ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ]; - const shouldUseConfiguredOCR = appConfig?.ocr != null && fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []); const shouldUseDocumentParser = - !shouldUseConfiguredOCR && documentParserMimeTypes.includes(file.mimetype); + !shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype)); const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser; diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 2938391ff2..7737255a52 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -83,6 +83,10 @@ const PDF_MIME = 'application/pdf'; const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const XLS_MIME = 'application/vnd.ms-excel'; +const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet'; +const ODT_MIME = 'application/vnd.oasis.opendocument.text'; +const ODP_MIME = 'application/vnd.oasis.opendocument.presentation'; +const ODG_MIME = 'application/vnd.oasis.opendocument.graphics'; const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({ user: { id: 'user-123' }, @@ -138,6 +142,9 @@ describe('processAgentFileUpload', () => { ['DOCX', DOCX_MIME], ['XLSX', XLSX_MIME], ['XLS', XLS_MIME], + ['ODS', ODS_MIME], + ['Excel variant (msexcel)', 'application/msexcel'], + ['Excel variant (x-msexcel)', 'application/x-msexcel'], ])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => { mergeFileConfig.mockReturnValue(makeFileConfig()); const req = makeReq({ mimetype: mime, ocrConfig: null }); @@ -229,6 +236,23 @@ describe('processAgentFileUpload', () => { expect(getStrategyFunctions).not.toHaveBeenCalled(); }); + test.each([ + ['ODT', ODT_MIME], + ['ODP', ODP_MIME], + ['ODG', ODG_MIME], + ])('routes %s through configured OCR when OCR supports the type', async (_, mime) => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] })); + const req = makeReq({ + mimetype: mime, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr); + }); + test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => { getStrategyFunctions.mockReturnValue({ handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')), diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts index 3b9e1636ef..a360b7f760 100644 --- a/packages/api/src/files/documents/crud.spec.ts +++ b/packages/api/src/files/documents/crud.spec.ts @@ -56,6 +56,50 @@ describe('Document Parser', () => { }); }); + test('parseDocument() parses text from ods', async () => { + const file = { + originalname: 'sample.ods', + path: path.join(__dirname, 'sample.ods'), + mimetype: 'application/vnd.oasis.opendocument.spreadsheet', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 66, + filename: 'sample.ods', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\nSecond Sheet:\nData,On\nSecond,Sheet\n', + }); + }); + + test.each([ + 'application/msexcel', + 'application/x-msexcel', + 'application/x-ms-excel', + 'application/x-excel', + 'application/x-dos_ms_excel', + 'application/xls', + 'application/x-xls', + ])('parseDocument() parses xls with variant MIME type: %s', async (mimetype) => { + const file = { + originalname: 'sample.xls', + path: path.join(__dirname, 'sample.xls'), + mimetype, + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 31, + filename: 'sample.xls', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\n', + }); + }); + test('parseDocument() throws error for unhandled document type', async () => { const file = { originalname: 'nonexistent.file', diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts index f2d45644d4..94a563bc96 100644 --- a/packages/api/src/files/documents/crud.ts +++ b/packages/api/src/files/documents/crud.ts @@ -1,12 +1,13 @@ import * as fs from 'fs'; -import { FileSources } from 'librechat-data-provider'; +import { excelMimeTypes, FileSources } from 'librechat-data-provider'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import type { MistralOCRUploadResult } from '~/types'; /** * Parses an uploaded document and extracts its text content and metadata. + * Handled types must stay in sync with `documentParserMimeTypes` from data-provider. * - * Throws an Error if it fails to parse or no text is found. + * @throws {Error} if `file.mimetype` is not handled or no text is found. */ export async function parseDocument({ file, @@ -14,19 +15,19 @@ export async function parseDocument({ file: Express.Multer.File; }): Promise { let text: string; - switch (file.mimetype) { - case 'application/pdf': - text = await pdfToText(file); - break; - case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': - text = await wordDocToText(file); - break; - case 'application/vnd.ms-excel': - case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': - text = await excelSheetToText(file); - break; - default: - throw new Error(`Unsupported file type in document parser: ${file.mimetype}`); + if (file.mimetype === 'application/pdf') { + text = await pdfToText(file); + } else if ( + file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) { + text = await wordDocToText(file); + } else if ( + excelMimeTypes.test(file.mimetype) || + file.mimetype === 'application/vnd.oasis.opendocument.spreadsheet' + ) { + text = await excelSheetToText(file); + } else { + throw new Error(`Unsupported file type in document parser: ${file.mimetype}`); } if (!text?.trim()) { diff --git a/packages/api/src/files/documents/sample.ods b/packages/api/src/files/documents/sample.ods new file mode 100644 index 0000000000000000000000000000000000000000..81e333dc2e76012c5ea4caaf08574a851258e0ca GIT binary patch literal 8040 zcmeGhO>f*pbPJ`WDF_aLxFA`sxPZLg?1E@kyIUbbBxuuyrbvj}#2)XO+8%4ho8A2Z zT=)fCIC4Uq=nvs1aO2bqC%9C+Hy`%c$tF%nq^cs3tsT$%nm2FW*X-SW`RZF8^LPEH zH~)I_Qm2E@+wgJs^GBb0U)att> zf_X}_VpxYCO5TRp&=RTdPBt8;Vi?5<^?(3hIVE^BPZBRD#cb^QZuPlFrTU!GFeYBH z%3(ny&yzSH1+3pc%)+jexN8gA6*;FQ6f;T-*FTdi0m}-I6g6#0WwS5nvhZ>GY=lLz zN~pMax+yKlB~UdM$HQoi9_(!Zs1LPDMy0iuhBeu%nR!2%z5nBtP6wY?!OYDH&TrW) zisFC{jh%W(Q5(0KYHQk>kTh&<>_Dn5HnLTRLg9wN*qs$cKJ@*3&XX?VQ$GxRnovAV z!r$y}g6S*RWfEtPOYp^F(OnEA!sgc2mM>>rM~hcGBa@N-irgn7DZ`Xzl#_z-$vz%| zFZZ2Zx8LgyU~f>wYI@|?kj<#3$j0t{pKSfmg&*ENV-G zMWB}cX$Kp`q})>mJQ*pOQ}rO9V>it=G5q5PobwX0V)Meoc&;W5ff)oy z%KiG#OK4PR^e`4VV2|C1B!WV;)0`v5K%B<#WJLdn%#+IV)K_=J1xZxVa6;Fgz2p4y zMyG?%O>h`i)V?^6p^5(QF3gtd8zfX8|3Wh!VJ zpD_r7T2w4MR1PJxwSKSnk#=E5Qh}k;MAJYoQ4wMdLf==@C9r_KTtoVs-Fy4NjHaXlj!y^n;tcE3fEp}WpPjymVm25Q z-wAsMIBwrsm457VnloM)K4*`|IwzkMX;Sj38ztoAIBQr;c^DoG3R^MoA?=4`!#j-W z;+DaXN#V(swB<`6gMsNRD0?Y`G3(K{P61zqYjC`rB>J)N# zL|7O#DK^|iAjF_DH_HqjJ@j$TlO2IIN!*5G>N7Gg7s5oO%?MJ z4D~zlN500ALz_%UR{x#IiV{d_aZe1NYK`)0{%gv<%GlOQX116 zwoM~4Prykj2;{qTpa^?qnoO(|ocGo^j;s0sO$i zdMMvgf&ZC4%Py086mwBrCIcBGTQR8X&f1#Vgwf}ETcA+)gpM?c7tkCYv@?gA&gyn< zYpy+waf- z#OF1r>CH_+7n;lGFX{n#_;7ni7x6G!o6q(7e!uSlgeO*z2ra!#+*0=#NMoC-IslR; z2gl?9+A3q$YCLEsIl@_Nh)OUh@YtLO5LI=y3Q7>_#=^toUKS0R&?N(Og!{5Z>qAy@ z>oau~u>+SUCK@*+!F`bY)<|bW%0zECBVrGVJFN)#a#oS@$5TjNQG#kw_VRV`Jfnyz zl*tPWZB-YhzkPY*@;s~0TKxI6hdqf)(cLZ(#*E~7)?T^z^XbkUjtj9jJRIBlnsn`9 z9r?%$yb*Z5D{#lV{^l3lpY?a_J#dpm^H#0G{}WglT{W?qw>cHo&)|kreYexZYTnmW xSpNW4Wz|i*X4|&H``|{ciQ7bM#)b;<*}L^fv3K{CYdCui{=bC_#=qag(Z9eHhcf^G literal 0 HcmV?d00001 diff --git a/packages/data-provider/src/file-config.spec.ts b/packages/data-provider/src/file-config.spec.ts index 4b9c866061..018b4dbfcf 100644 --- a/packages/data-provider/src/file-config.spec.ts +++ b/packages/data-provider/src/file-config.spec.ts @@ -3,9 +3,122 @@ import { fileConfig as baseFileConfig, getEndpointFileConfig, mergeFileConfig, + applicationMimeTypes, + defaultOCRMimeTypes, + documentParserMimeTypes, + supportedMimeTypes, } from './file-config'; import { EModelEndpoint } from './schemas'; +describe('applicationMimeTypes', () => { + const odfTypes = [ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ]; + + it.each(odfTypes)('matches ODF type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(true); + }); + + const existingTypes = [ + 'application/pdf', + 'application/json', + 'application/csv', + 'application/msword', + 'application/xml', + 'application/zip', + 'application/epub+zip', + 'application/x-tar', + 'application/x-sh', + 'application/typescript', + 'application/sql', + 'application/yaml', + 'application/x-parquet', + 'application/vnd.apache.parquet', + 'application/vnd.coffeescript', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + it.each(existingTypes)('matches existing type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(true); + }); + + const invalidTypes = [ + 'application/vnd.oasis.opendocument.text-template', + 'application/vnd.oasis.opendocument.texts', + 'application/vnd.oasis.opendocument.chart', + 'application/vnd.oasis.opendocument.formula', + 'application/vnd.oasis.opendocument.image', + 'application/vnd.oasis.opendocument.text-master', + 'text/plain', + 'image/png', + ]; + + it.each(invalidTypes)('does not match invalid type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(false); + }); +}); + +describe('defaultOCRMimeTypes', () => { + const checkOCRType = (mimeType: string): boolean => + defaultOCRMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ])('matches ODF type for OCR: %s', (mimeType) => { + expect(checkOCRType(mimeType)).toBe(true); + }); +}); + +describe('supportedMimeTypes', () => { + const checkSupported = (mimeType: string): boolean => + supportedMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ])('ODF type flows through supportedMimeTypes: %s', (mimeType) => { + expect(checkSupported(mimeType)).toBe(true); + }); +}); + +describe('documentParserMimeTypes', () => { + const check = (mimeType: string): boolean => + documentParserMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/msexcel', + 'application/x-msexcel', + 'application/x-ms-excel', + 'application/vnd.oasis.opendocument.spreadsheet', + ])('matches natively parseable type: %s', (mimeType) => { + expect(check(mimeType)).toBe(true); + }); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + 'text/plain', + 'image/png', + ])('does not match OCR-only or unsupported type: %s', (mimeType) => { + expect(check(mimeType)).toBe(false); + }); +}); + describe('getEndpointFileConfig', () => { describe('custom endpoint lookup', () => { it('should find custom endpoint by direct lookup', () => { diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 5a117eb760..033c868a80 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -61,6 +61,10 @@ export const fullMimeTypesList = [ 'application/xml', 'application/zip', 'application/x-parquet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', 'image/svg', 'image/svg+xml', // Video formats @@ -179,7 +183,7 @@ export const textMimeTypes = /^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml))$/; export const applicationMimeTypes = - /^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; + /^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/; export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; @@ -190,10 +194,20 @@ export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv export const defaultOCRMimeTypes = [ imageMimeTypes, + excelMimeTypes, /^application\/pdf$/, - /^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)$/, - /^application\/vnd\.ms-(word|powerpoint|excel)$/, + /^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)$/, + /^application\/vnd\.ms-(word|powerpoint)$/, /^application\/epub\+zip$/, + /^application\/vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)$/, +]; + +/** MIME types handled by the built-in document parser (pdf, docx, excel variants, ods) */ +export const documentParserMimeTypes = [ + excelMimeTypes, + /^application\/pdf$/, + /^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/, + /^application\/vnd\.oasis\.opendocument\.spreadsheet$/, ]; export const defaultTextMimeTypes = [/^[\w.-]+\/[\w.-]+$/]; @@ -331,6 +345,10 @@ export const codeTypeMapping: { [key: string]: string } = { tcl: 'text/plain', // .tcl - Tcl source awk: 'text/plain', // .awk - AWK script sed: 'text/plain', // .sed - Sed script + odt: 'application/vnd.oasis.opendocument.text', // .odt - OpenDocument Text + ods: 'application/vnd.oasis.opendocument.spreadsheet', // .ods - OpenDocument Spreadsheet + odp: 'application/vnd.oasis.opendocument.presentation', // .odp - OpenDocument Presentation + odg: 'application/vnd.oasis.opendocument.graphics', // .odg - OpenDocument Graphics }; /** Maps image extensions to MIME types for formats browsers may not recognize */ From 0568f1c1ebc54d8901ad3c83c7740aae19049f69 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:10:14 -0500 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=AA=83=20fix:=20Prevent=20Recursive?= =?UTF-8?q?=20Login=20Redirect=20Loop=20(#11964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Prevent recursive login redirect loop buildLoginRedirectUrl() would blindly encode the current URL into a redirect_to param even when already on /login, causing infinite nesting (/login?redirect_to=%2Flogin%3Fredirect_to%3D...). Guard at source so it returns plain /login when pathname starts with /login. Also validates redirect_to in the login error handler with isSafeRedirect to close an open-redirect vector, and removes a redundant /login guard from useAuthRedirect now handled by the centralized check. * 🔀 fix: Handle basename-prefixed login paths and remove double URL decoding buildLoginRedirectUrl now uses isLoginPath() which matches /login, /librechat/login, and /login/2fa — covering subdirectory deployments where window.location.pathname includes the basename prefix. Remove redundant decodeURIComponent calls on URLSearchParams.get() results (which already returns decoded values) in getPostLoginRedirect, Login.tsx, and AuthContext login error handler. The extra decode could throw URIError on inputs containing literal percent signs. * 🔀 fix: Tighten login path matching and add onError redirect tests Replace overbroad `endsWith('/login')` with a single regex `/(^|\/)login(\/|$)/` that matches `/login` only as a full path segment. Unifies `isSafeRedirect` and `buildLoginRedirectUrl` to use the same `LOGIN_PATH_RE` constant — no more divergent definitions. Add tests for the AuthContext onError redirect_to preservation logic (valid path preserved, open-redirect blocked, /login loop blocked), and a false-positive guard proving `/c/loginhistory` is not matched. Update JSDoc on `buildLoginRedirectUrl` to document the plain `/login` early-return, and add explanatory comment in AuthContext `onError` for why `buildLoginRedirectUrl()` cannot be used there. * test: Add unit tests for AuthContextProvider login error handling Introduced a new test suite for AuthContextProvider to validate the handling of login errors and the preservation of redirect parameters. The tests cover various scenarios including valid redirect preservation, open-redirect prevention, and recursive redirect prevention. This enhances the robustness of the authentication flow and ensures proper navigation behavior during login failures. --- client/src/components/Auth/Login.tsx | 2 +- client/src/hooks/AuthContext.tsx | 8 +- .../src/hooks/__tests__/AuthContext.spec.tsx | 174 ++++++++++++++++++ client/src/routes/useAuthRedirect.ts | 5 - client/src/utils/__tests__/redirect.test.ts | 69 +++++++ client/src/utils/redirect.ts | 16 +- 6 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 client/src/hooks/__tests__/AuthContext.spec.tsx diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index e0bf89bacd..7c3adf51bd 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -29,7 +29,7 @@ function Login() { useEffect(() => { const redirectTo = searchParams.get('redirect_to'); if (redirectTo) { - persistRedirectToSession(decodeURIComponent(redirectTo)); + persistRedirectToSession(redirectTo); } else { const state = location.state as LoginLocationState | null; if (state?.redirect_to) { diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 04bc3445c9..ca69a68f8b 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -93,8 +93,14 @@ const AuthContextProvider = ({ onError: (error: TResError | unknown) => { const resError = error as TResError; doSetError(resError.message); + // Preserve a valid redirect_to across login failures so the deep link survives retries. + // Cannot use buildLoginRedirectUrl() here — it reads the current pathname (already /login) + // and would return plain /login, dropping the redirect_to destination. const redirectTo = new URLSearchParams(window.location.search).get('redirect_to'); - const loginPath = redirectTo ? `/login?redirect_to=${redirectTo}` : '/login'; + const loginPath = + redirectTo && isSafeRedirect(redirectTo) + ? `/login?redirect_to=${encodeURIComponent(redirectTo)}` + : '/login'; navigate(loginPath, { replace: true }); }, }); diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx new file mode 100644 index 0000000000..5a24a31ec4 --- /dev/null +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +import type { TAuthConfig } from '~/common'; + +import { AuthContextProvider, useAuthContext } from '../AuthContext'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + setTokenHeader: jest.fn(), +})); + +let mockCapturedLoginOptions: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; +}; + +jest.mock('~/data-provider', () => ({ + useLoginUserMutation: jest.fn( + (options: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; + }) => { + mockCapturedLoginOptions = options; + return { mutate: jest.fn() }; + }, + ), + useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })), + useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })), + useGetUserQuery: jest.fn(() => ({ + data: undefined, + isError: false, + error: null, + })), + useGetRole: jest.fn(() => ({ data: null })), +})); + +const authConfig: TAuthConfig = { loginRedirect: '/login', test: true }; + +function TestConsumer() { + const ctx = useAuthContext(); + return
; +} + +function renderProvider() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + + + + + + , + ); +} + +describe('AuthContextProvider — login onError redirect handling', () => { + const originalLocation = window.location; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { ...originalLocation, pathname: '/login', search: '', hash: '' }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + it('preserves a valid redirect_to param across login failure', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc123', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login?redirect_to=%2Fc%2Fabc123', { + replace: true, + }); + }); + + it('drops redirect_to when it contains an absolute URL (open-redirect prevention)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=https%3A%2F%2Fevil.com', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('drops redirect_to when it points to /login (recursive redirect prevention)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Flogin', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('navigates to plain /login when no redirect_to param exists', () => { + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Server error' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('preserves redirect_to with query params and hash', () => { + const target = '/c/abc123?model=gpt-4#section'; + Object.defineProperty(window, 'location', { + value: { + pathname: '/login', + search: `?redirect_to=${encodeURIComponent(target)}`, + hash: '', + }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + const navigatedUrl = mockNavigate.mock.calls[0][0] as string; + const params = new URLSearchParams(navigatedUrl.split('?')[1]); + expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target); + }); +}); diff --git a/client/src/routes/useAuthRedirect.ts b/client/src/routes/useAuthRedirect.ts index 7303952155..5508162543 100644 --- a/client/src/routes/useAuthRedirect.ts +++ b/client/src/routes/useAuthRedirect.ts @@ -14,11 +14,6 @@ export default function useAuthRedirect() { return; } - if (location.pathname.startsWith('/login')) { - navigate('/login', { replace: true }); - return; - } - navigate(buildLoginRedirectUrl(location.pathname, location.search, location.hash), { replace: true, }); diff --git a/client/src/utils/__tests__/redirect.test.ts b/client/src/utils/__tests__/redirect.test.ts index 36336b0d94..1d402d2025 100644 --- a/client/src/utils/__tests__/redirect.test.ts +++ b/client/src/utils/__tests__/redirect.test.ts @@ -100,6 +100,45 @@ describe('buildLoginRedirectUrl', () => { const result = buildLoginRedirectUrl(); expect(result).toBe('/login?redirect_to=%2F'); }); + + it('returns plain /login when pathname is /login (prevents recursive redirect)', () => { + const result = buildLoginRedirectUrl('/login', '?redirect_to=%2Fc%2Fnew', ''); + expect(result).toBe('/login'); + }); + + it('returns plain /login when window.location is already /login', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login'); + }); + + it('returns plain /login for /login sub-paths', () => { + const result = buildLoginRedirectUrl('/login/2fa', '', ''); + expect(result).toBe('/login'); + }); + + it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login'); + }); + + it('returns plain /login for basename-prefixed /login sub-paths', () => { + const result = buildLoginRedirectUrl('/librechat/login/2fa', '', ''); + expect(result).toBe('/login'); + }); + + it('does NOT match paths where "login" is a substring of a segment', () => { + const result = buildLoginRedirectUrl('/c/loginhistory', '', ''); + expect(result).toContain('redirect_to='); + expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory'); + }); }); describe('getPostLoginRedirect', () => { @@ -170,6 +209,36 @@ describe('getPostLoginRedirect', () => { }); }); +describe('login error redirect_to preservation (AuthContext onError pattern)', () => { + /** Mirrors the logic in AuthContext.tsx loginUser.onError */ + function buildLoginErrorPath(search: string): string { + const redirectTo = new URLSearchParams(search).get('redirect_to'); + return redirectTo && isSafeRedirect(redirectTo) + ? `/login?redirect_to=${encodeURIComponent(redirectTo)}` + : '/login'; + } + + it('preserves a valid redirect_to across login failure', () => { + const result = buildLoginErrorPath('?redirect_to=%2Fc%2Fnew'); + expect(result).toBe('/login?redirect_to=%2Fc%2Fnew'); + }); + + it('drops an open-redirect attempt (absolute URL)', () => { + const result = buildLoginErrorPath('?redirect_to=https%3A%2F%2Fevil.com'); + expect(result).toBe('/login'); + }); + + it('drops a /login redirect_to to prevent loops', () => { + const result = buildLoginErrorPath('?redirect_to=%2Flogin'); + expect(result).toBe('/login'); + }); + + it('returns plain /login when no redirect_to param exists', () => { + const result = buildLoginErrorPath(''); + expect(result).toBe('/login'); + }); +}); + describe('persistRedirectToSession', () => { beforeEach(() => { sessionStorage.clear(); diff --git a/client/src/utils/redirect.ts b/client/src/utils/redirect.ts index d2b7588151..1fb4e66d41 100644 --- a/client/src/utils/redirect.ts +++ b/client/src/utils/redirect.ts @@ -1,18 +1,27 @@ const REDIRECT_PARAM = 'redirect_to'; const SESSION_KEY = 'post_login_redirect_to'; +/** Matches `/login` as a full path segment, with optional basename prefix (e.g. `/librechat/login/2fa`) */ +const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/; + /** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */ function isSafeRedirect(url: string): boolean { if (!url.startsWith('/') || url.startsWith('//')) { return false; } const path = url.split('?')[0].split('#')[0]; - return !path.startsWith('/login'); + return !LOGIN_PATH_RE.test(path); } -/** Builds a `/login?redirect_to=...` URL, reading from window.location when no args are provided */ +/** + * Builds a `/login?redirect_to=...` URL from the given or current location. + * Returns plain `/login` (no param) when already on a login route to prevent recursive nesting. + */ function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string { const p = pathname ?? window.location.pathname; + if (LOGIN_PATH_RE.test(p)) { + return '/login'; + } const s = search ?? window.location.search; const h = hash ?? window.location.hash; const currentPath = `${p}${s}${h}`; @@ -25,8 +34,7 @@ function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string * cleans up both sources, and returns the validated target (or null). */ function getPostLoginRedirect(searchParams: URLSearchParams): string | null { - const encoded = searchParams.get(REDIRECT_PARAM); - const urlRedirect = encoded ? decodeURIComponent(encoded) : null; + const urlRedirect = searchParams.get(REDIRECT_PARAM); const storedRedirect = sessionStorage.getItem(SESSION_KEY); const target = urlRedirect ?? storedRedirect; From 09d5b1a7391f2353cd257f80cfa61ca04d41dd9c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:10:33 -0500 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=A6=20chore:=20bump=20`minimatch`?= =?UTF-8?q?=20due=20to=20ReDoS=20vulnerability,=20bump=20`rimraf`,=20`roll?= =?UTF-8?q?up`=20(#11963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: bump minimatch due to ReDoS vulnerability - Removed deprecated dependencies: @isaacs/balanced-match and @isaacs/brace-expansion. - Upgraded Rollup packages from version 4.37.0 to 4.59.0 for improved performance and stability across multiple platforms. * 🔧 chore: update Rollup version across multiple packages - Bumped Rollup dependency from various versions to 4.34.9 in package.json and package-lock.json files for improved performance and compatibility across the project. * 🔧 chore: update rimraf dependency to version 6.1.3 across multiple packages - Bumped rimraf version from 6.1.2 to 6.1.3 in package.json and package-lock.json files for improved performance and compatibility. --- package-lock.json | 502 ++++++++++++++++------------ packages/api/package.json | 4 +- packages/client/package.json | 4 +- packages/data-provider/package.json | 4 +- packages/data-schemas/package.json | 4 +- 5 files changed, 298 insertions(+), 220 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbb379c4d4..1ad97628a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10439,29 +10439,6 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -18697,9 +18674,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz", - "integrity": "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -18711,9 +18688,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz", - "integrity": "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -18725,9 +18702,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.37.0.tgz", - "integrity": "sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -18739,9 +18716,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.37.0.tgz", - "integrity": "sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -18753,9 +18730,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.37.0.tgz", - "integrity": "sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -18767,9 +18744,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.37.0.tgz", - "integrity": "sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -18781,9 +18758,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.37.0.tgz", - "integrity": "sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -18795,9 +18772,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.37.0.tgz", - "integrity": "sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -18809,9 +18786,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.37.0.tgz", - "integrity": "sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -18823,9 +18800,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.37.0.tgz", - "integrity": "sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -18836,10 +18813,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.37.0.tgz", - "integrity": "sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -18850,10 +18827,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.37.0.tgz", - "integrity": "sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -18865,9 +18870,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.37.0.tgz", - "integrity": "sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -18879,9 +18884,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.37.0.tgz", - "integrity": "sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -18893,9 +18898,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.37.0.tgz", - "integrity": "sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -18907,9 +18912,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.37.0.tgz", - "integrity": "sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -18921,9 +18926,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.37.0.tgz", - "integrity": "sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -18934,10 +18939,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.37.0.tgz", - "integrity": "sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -18949,9 +18982,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.37.0.tgz", - "integrity": "sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -18962,10 +18995,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.37.0.tgz", - "integrity": "sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -20679,9 +20726,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -21233,13 +21280,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -21749,9 +21796,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22074,9 +22121,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -22589,9 +22636,9 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, "license": "MIT" }, @@ -23876,9 +23923,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -25292,9 +25339,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -25519,9 +25566,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", - "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -27150,10 +27197,11 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -27775,18 +27823,18 @@ } }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -27803,36 +27851,59 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -27840,7 +27911,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -28351,9 +28422,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -30742,13 +30813,13 @@ } }, "node_modules/jest/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -33711,9 +33782,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -33814,9 +33885,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -37336,9 +37408,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -37359,9 +37431,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -39087,12 +39159,12 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -39168,13 +39240,13 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.37.0.tgz", - "integrity": "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -39184,26 +39256,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.37.0", - "@rollup/rollup-android-arm64": "4.37.0", - "@rollup/rollup-darwin-arm64": "4.37.0", - "@rollup/rollup-darwin-x64": "4.37.0", - "@rollup/rollup-freebsd-arm64": "4.37.0", - "@rollup/rollup-freebsd-x64": "4.37.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.37.0", - "@rollup/rollup-linux-arm-musleabihf": "4.37.0", - "@rollup/rollup-linux-arm64-gnu": "4.37.0", - "@rollup/rollup-linux-arm64-musl": "4.37.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.37.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.37.0", - "@rollup/rollup-linux-riscv64-gnu": "4.37.0", - "@rollup/rollup-linux-riscv64-musl": "4.37.0", - "@rollup/rollup-linux-s390x-gnu": "4.37.0", - "@rollup/rollup-linux-x64-gnu": "4.37.0", - "@rollup/rollup-linux-x64-musl": "4.37.0", - "@rollup/rollup-win32-arm64-msvc": "4.37.0", - "@rollup/rollup-win32-ia32-msvc": "4.37.0", - "@rollup/rollup-win32-x64-msvc": "4.37.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -40557,12 +40634,12 @@ } }, "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -43097,10 +43174,11 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -43702,8 +43780,8 @@ "mammoth": "^1.11.0", "mongodb": "^6.14.2", "pdfjs-dist": "^5.4.624", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", "typescript": "^5.0.4", @@ -43769,13 +43847,13 @@ } }, "packages/api/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -43820,8 +43898,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.4.0", - "rimraf": "^6.1.2", - "rollup": "^4.0.0", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.35.0", @@ -45943,13 +46021,13 @@ } }, "packages/client/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -46106,8 +46184,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "openapi-types": "^12.1.3", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "typescript": "^5.0.4" @@ -46117,13 +46195,13 @@ } }, "packages/data-provider/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -46154,8 +46232,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "mongodb-memory-server": "^10.1.4", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "ts-node": "^10.9.2", @@ -46200,13 +46278,13 @@ } }, "packages/data-schemas/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { diff --git a/packages/api/package.json b/packages/api/package.json index 1854457b42..903e15947b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -70,8 +70,8 @@ "mammoth": "^1.11.0", "mongodb": "^6.14.2", "pdfjs-dist": "^5.4.624", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", "typescript": "^5.0.4", diff --git a/packages/client/package.json b/packages/client/package.json index 118186c9a9..98a1cd7e3c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -104,8 +104,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.4.0", - "rimraf": "^6.1.2", - "rollup": "^4.0.0", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.35.0", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 5d6c6b8e46..d8cbe63f8b 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -62,8 +62,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "openapi-types": "^12.1.3", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "typescript": "^5.0.4" diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 0c401c5a24..57c1d21234 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -50,8 +50,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "mongodb-memory-server": "^10.1.4", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "ts-node": "^10.9.2", From b01f3ccada1243c76c390ce880485cd906bcf6ba Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:43:24 -0500 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Redirect=20Stability?= =?UTF-8?q?=20and=20Build=20Chunking=20(#11965)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: Update Vite configuration to include additional package checks - Enhanced the Vite configuration to recognize 'dnd-core' and 'flip-toolkit' alongside existing checks for 'react-dnd' and 'react-flip-toolkit' for improved handling of React interactions. - Updated the markdown highlighting logic to also include 'lowlight' in addition to 'highlight.js' for better syntax highlighting support. * 🔧 fix: Update AuthContextProvider to prevent infinite re-fire of useEffect - Modified the dependency array of the useEffect hook in AuthContextProvider to an empty array, preventing unnecessary re-executions and potential infinite loops. Added an ESLint comment to clarify the decision regarding stable dependencies at mount. * chore: import order --- client/src/hooks/AuthContext.tsx | 5 +++-- client/vite.config.ts | 23 +++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index ca69a68f8b..86f80cde6b 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -11,8 +11,8 @@ import { debounce } from 'lodash'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { setTokenHeader, SystemRoles } from 'librechat-data-provider'; -import type { ReactNode } from 'react'; import type * as t from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import { useGetRole, useGetUserQuery, @@ -167,7 +167,8 @@ const AuthContextProvider = ({ navigate(buildLoginRedirectUrl()); }, }); - }, [authConfig?.test, refreshToken, setUserContext, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are stable at mount; adding refreshToken causes infinite re-fire + }, []); useEffect(() => { if (userQuery.data) { diff --git a/client/vite.config.ts b/client/vite.config.ts index b3f6541ab3..a185215837 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -8,15 +8,18 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ -const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080; -const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`; +const backendPort = (process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT)) || 3080; +const backendURL = process.env.HOST + ? `http://${process.env.HOST}:${backendPort}` + : `http://localhost:${backendPort}`; export default defineConfig(({ command }) => ({ base: '', server: { - allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [], + allowedHosts: + (process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',')) || [], host: process.env.HOST || 'localhost', - port: process.env.PORT && Number(process.env.PORT) || 3090, + port: (process.env.PORT && Number(process.env.PORT)) || 3090, strictPort: false, proxy: { '/api': { @@ -143,7 +146,12 @@ export default defineConfig(({ command }) => ({ if (normalizedId.includes('@dicebear')) { return 'avatars'; } - if (normalizedId.includes('react-dnd') || normalizedId.includes('react-flip-toolkit')) { + if ( + normalizedId.includes('react-dnd') || + normalizedId.includes('dnd-core') || + normalizedId.includes('react-flip-toolkit') || + normalizedId.includes('flip-toolkit') + ) { return 'react-interactions'; } if (normalizedId.includes('react-hook-form')) { @@ -219,7 +227,10 @@ export default defineConfig(({ command }) => ({ if (normalizedId.includes('framer-motion')) { return 'framer-motion'; } - if (normalizedId.includes('node_modules/highlight.js')) { + if ( + normalizedId.includes('node_modules/highlight.js') || + normalizedId.includes('node_modules/lowlight') + ) { return 'markdown_highlight'; } if (normalizedId.includes('katex') || normalizedId.includes('node_modules/katex')) { From a17a38b8ede3ed5aab55626a78b32344199e44f3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:24:02 +0100 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=9A=85=20docs:=20update=20Railway=20t?= =?UTF-8?q?emplate=20link=20(#11966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update railway template link * Fix link for Deploy on Railway button in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e04396637..e82b3ebc2c 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@

- - Deploy on Railway + + Deploy on Railway Deploy on Zeabur