diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..725ac8b6bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for shell scripts and git hooks (required for cross-platform compatibility) +.husky/* text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 038c90627e..9dd3905c0e 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -97,6 +97,65 @@ jobs: path: packages/api/dist retention-days: 2 + typecheck: + name: TypeScript type checks + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20.19 + uses: actions/setup-node@v4 + with: + node-version: '20.19' + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + api/node_modules + packages/api/node_modules + packages/data-provider/node_modules + packages/data-schemas/node_modules + key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Download data-provider build + uses: actions/download-artifact@v4 + with: + name: build-data-provider + path: packages/data-provider/dist + + - name: Download data-schemas build + uses: actions/download-artifact@v4 + with: + name: build-data-schemas + path: packages/data-schemas/dist + + - name: Download api build + uses: actions/download-artifact@v4 + with: + name: build-api + path: packages/api/dist + + - name: Type check data-provider + run: npx tsc --noEmit -p packages/data-provider/tsconfig.json + + - name: Type check data-schemas + run: npx tsc --noEmit -p packages/data-schemas/tsconfig.json + + - name: Type check @librechat/api + run: npx tsc --noEmit -p packages/api/tsconfig.json + + - name: Type check @librechat/client + run: npx tsc --noEmit -p packages/client/tsconfig.json + circular-deps: name: Circular dependency checks needs: build diff --git a/AGENTS.md b/AGENTS.md index ec44607aa7..81362cfc57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,12 @@ The source code for `@librechat/agents` (major backend dependency, same team) is ## Code Style +### Naming and File Organization + +- **Single-word file names** whenever possible (e.g., `permissions.ts`, `capabilities.ts`, `service.ts`). +- When multiple words are needed, prefer grouping related modules under a **single-word directory** rather than using multi-word file names (e.g., `admin/capabilities.ts` not `adminCapabilities.ts`). +- The directory already provides context — `app/service.ts` not `app/appConfigService.ts`. + ### Structure and Clarity - **Never-nesting**: early returns, flat code, minimal indentation. Break complex operations into well-named helpers. diff --git a/README.md b/README.md index 7da34974e3..a7f68d9a92 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@
-
+
-
+
diff --git a/README.zh.md b/README.zh.md
index cc9cb5a205..7f74057413 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,4 +1,4 @@
-
+
diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index ec5ccfb5f4..08cb1f6ada 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -32,7 +32,6 @@ class BaseClient {
constructor(apiKey, options = {}) {
this.apiKey = apiKey;
this.sender = options.sender ?? 'AI';
- this.contextStrategy = null;
this.currentDateString = new Date().toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js
index 4b86101425..8adb43f945 100644
--- a/api/app/clients/tools/util/handleTools.js
+++ b/api/app/clients/tools/util/handleTools.js
@@ -14,7 +14,6 @@ const {
buildImageToolContext,
buildWebSearchContext,
} = require('@librechat/api');
-const { getMCPServersRegistry } = require('~/config');
const {
Tools,
Constants,
@@ -39,12 +38,13 @@ const {
createGeminiImageTool,
createOpenAIImageTools,
} = require('../');
-const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
+const { createMCPTool, createMCPTools, resolveConfigServers } = require('~/server/services/MCP');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
+const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
-const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getMCPServerTools } = require('~/server/services/Config');
+const { getMCPServersRegistry } = require('~/config');
const { getRoleByName } = require('~/models');
/**
@@ -256,6 +256,12 @@ const loadTools = async ({
const toolContextMap = {};
const requestedMCPTools = {};
+ /** Resolve config-source servers for the current user/tenant context */
+ let configServers;
+ if (tools.some((tool) => tool && mcpToolPattern.test(tool))) {
+ configServers = await resolveConfigServers(options.req);
+ }
+
for (const tool of tools) {
if (tool === Tools.execute_code) {
requestedTools[tool] = async () => {
@@ -341,7 +347,7 @@ const loadTools = async ({
continue;
}
const serverConfig = serverName
- ? await getMCPServersRegistry().getServerConfig(serverName, user)
+ ? await getMCPServersRegistry().getServerConfig(serverName, user, configServers)
: null;
if (!serverConfig) {
logger.warn(
@@ -419,6 +425,7 @@ const loadTools = async ({
let index = -1;
const failedMCPServers = new Set();
const safeUser = createSafeUser(options.req?.user);
+
for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
index++;
/** @type {LCAvailableTools} */
@@ -433,6 +440,7 @@ const loadTools = async ({
signal,
user: safeUser,
userMCPAuthMap,
+ configServers,
res: options.res,
streamId: options.req?._resumableStreamId || null,
model: agent?.model ?? model,
diff --git a/api/server/cleanup.js b/api/server/cleanup.js
index 364c02cd8a..c27814292d 100644
--- a/api/server/cleanup.js
+++ b/api/server/cleanup.js
@@ -123,9 +123,6 @@ function disposeClient(client) {
if (client.maxContextTokens) {
client.maxContextTokens = null;
}
- if (client.contextStrategy) {
- client.contextStrategy = null;
- }
if (client.currentDateString) {
client.currentDateString = null;
}
diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js
index 805d9eef27..1741d3f6b1 100644
--- a/api/server/controllers/ModelController.js
+++ b/api/server/controllers/ModelController.js
@@ -1,5 +1,5 @@
-const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
+const { logger, scopedCacheKey } = require('@librechat/data-schemas');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@@ -9,7 +9,8 @@ const { getLogStores } = require('~/cache');
*/
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
- let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
+ const cacheKey = scopedCacheKey(CacheKeys.MODELS_CONFIG);
+ let modelsConfig = await cache.get(cacheKey);
if (!modelsConfig) {
modelsConfig = await loadModels(req);
}
@@ -24,7 +25,8 @@ const getModelsConfig = async (req) => {
*/
async function loadModels(req) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
- const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
+ const cacheKey = scopedCacheKey(CacheKeys.MODELS_CONFIG);
+ const cachedModelsConfig = await cache.get(cacheKey);
if (cachedModelsConfig) {
return cachedModelsConfig;
}
@@ -33,7 +35,7 @@ async function loadModels(req) {
const modelConfig = { ...defaultModelsConfig, ...customModelsConfig };
- await cache.set(CacheKeys.MODELS_CONFIG, modelConfig);
+ await cache.set(cacheKey, modelConfig);
return modelConfig;
}
diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js
index 279ffb15fd..7c47fe4d57 100644
--- a/api/server/controllers/PluginController.js
+++ b/api/server/controllers/PluginController.js
@@ -1,5 +1,5 @@
-const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
+const { logger, scopedCacheKey } = require('@librechat/data-schemas');
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools');
@@ -9,13 +9,14 @@ const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.TOOL_CACHE);
- const cachedPlugins = await cache.get(CacheKeys.PLUGINS);
+ const pluginsCacheKey = scopedCacheKey(CacheKeys.PLUGINS);
+ const cachedPlugins = await cache.get(pluginsCacheKey);
if (cachedPlugins) {
res.status(200).json(cachedPlugins);
return;
}
- const appConfig = await getAppConfig({ role: req.user?.role });
+ const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
/** @type {{ filteredTools: string[], includedTools: string[] }} */
const { filteredTools = [], includedTools = [] } = appConfig;
/** @type {import('@librechat/api').LCManifestTool[]} */
@@ -37,7 +38,7 @@ const getAvailablePluginsController = async (req, res) => {
plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey));
}
- await cache.set(CacheKeys.PLUGINS, plugins);
+ await cache.set(pluginsCacheKey, plugins);
res.status(200).json(plugins);
} catch (error) {
res.status(500).json({ message: error.message });
@@ -64,9 +65,11 @@ const getAvailableTools = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
const cache = getLogStores(CacheKeys.TOOL_CACHE);
- const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
+ const toolsCacheKey = scopedCacheKey(CacheKeys.TOOLS);
+ const cachedToolsArray = await cache.get(toolsCacheKey);
- const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
+ const appConfig =
+ req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }));
// Return early if we have cached tools
if (cachedToolsArray != null) {
@@ -114,7 +117,7 @@ const getAvailableTools = async (req, res) => {
}
const finalTools = filterUniquePlugins(toolsOutput);
- await cache.set(CacheKeys.TOOLS, finalTools);
+ await cache.set(toolsCacheKey, finalTools);
res.status(200).json(finalTools);
} catch (error) {
diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js
index 06a51a3bd6..fdbc2401ce 100644
--- a/api/server/controllers/PluginController.spec.js
+++ b/api/server/controllers/PluginController.spec.js
@@ -8,6 +8,7 @@ jest.mock('@librechat/data-schemas', () => ({
error: jest.fn(),
warn: jest.fn(),
},
+ scopedCacheKey: jest.fn((key) => key),
}));
jest.mock('~/server/services/Config', () => ({
diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js
index 301c6d2f76..16b68968d9 100644
--- a/api/server/controllers/UserController.js
+++ b/api/server/controllers/UserController.js
@@ -26,7 +26,7 @@ const { getLogStores } = require('~/cache');
const db = require('~/models');
const getUserController = async (req, res) => {
- const appConfig = await getAppConfig({ role: req.user?.role });
+ const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
/** @type {IUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/**
@@ -165,7 +165,7 @@ const deleteUserMcpServers = async (userId) => {
};
const updateUserPluginsController = async (req, res) => {
- const appConfig = await getAppConfig({ role: req.user?.role });
+ const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body;
try {
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
index 47a10165e3..d6795a4be9 100644
--- a/api/server/controllers/agents/client.js
+++ b/api/server/controllers/agents/client.js
@@ -50,6 +50,7 @@ const {
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { createContextHandlers } = require('~/app/clients/prompts');
+const { resolveConfigServers } = require('~/server/services/MCP');
const { getMCPServerTools } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient');
const { getMCPManager } = require('~/config');
@@ -377,6 +378,9 @@ class AgentClient extends BaseClient {
*/
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
+
+ const configServers = await resolveConfigServers(this.options.req);
+
await Promise.all(
allAgents.map(({ agent, agentId }) =>
applyContextToAgent({
@@ -384,6 +388,7 @@ class AgentClient extends BaseClient {
agentId,
logger,
mcpManager,
+ configServers,
sharedRunContext,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
}),
diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js
index 41a806f66d..1595f652f7 100644
--- a/api/server/controllers/agents/client.test.js
+++ b/api/server/controllers/agents/client.test.js
@@ -22,6 +22,10 @@ jest.mock('~/server/services/Config', () => ({
getMCPServerTools: jest.fn(),
}));
+jest.mock('~/server/services/MCP', () => ({
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+}));
+
jest.mock('~/models', () => ({
getAgent: jest.fn(),
getRoleByName: jest.fn(),
@@ -1315,7 +1319,7 @@ describe('AgentClient - titleConvo', () => {
});
// Verify formatInstructionsForContext was called with correct server names
- expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
+ expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2'], {});
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
@@ -1355,10 +1359,10 @@ describe('AgentClient - titleConvo', () => {
});
// Verify formatInstructionsForContext was called with ephemeral server names
- expect(mockFormatInstructions).toHaveBeenCalledWith([
- 'ephemeral-server1',
- 'ephemeral-server2',
- ]);
+ expect(mockFormatInstructions).toHaveBeenCalledWith(
+ ['ephemeral-server1', 'ephemeral-server2'],
+ {},
+ );
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js
index 729f01da9d..e31bb93bc6 100644
--- a/api/server/controllers/mcp.js
+++ b/api/server/controllers/mcp.js
@@ -14,6 +14,7 @@ const {
isMCPInspectionFailedError,
} = require('@librechat/api');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
+const { resolveConfigServers, resolveAllMcpConfigs } = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
@@ -57,7 +58,7 @@ function handleMCPError(error, res) {
}
/**
- * Get all MCP tools available to the user
+ * Get all MCP tools available to the user.
*/
const getMCPTools = async (req, res) => {
try {
@@ -67,10 +68,10 @@ const getMCPTools = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
- const mcpConfig = await getMCPServersRegistry().getAllServerConfigs(userId);
- const configuredServers = mcpConfig ? Object.keys(mcpConfig) : [];
+ const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
+ const configuredServers = Object.keys(mcpConfig);
- if (!mcpConfig || Object.keys(mcpConfig).length == 0) {
+ if (!configuredServers.length) {
return res.status(200).json({ servers: {} });
}
@@ -115,14 +116,11 @@ const getMCPTools = async (req, res) => {
try {
const serverTools = serverToolsMap.get(serverName);
- // Get server config once
const serverConfig = mcpConfig[serverName];
- const rawServerConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
- // Initialize server object with all server-level data
const server = {
name: serverName,
- icon: rawServerConfig?.iconPath || '',
+ icon: serverConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
@@ -183,7 +181,7 @@ const getMCPServersList = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
- const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
+ const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
return res.json(redactAllServerSecrets(serverConfigs));
} catch (error) {
logger.error('[getMCPServersList]', error);
@@ -237,7 +235,12 @@ const getMCPServerById = async (req, res) => {
if (!serverName) {
return res.status(400).json({ message: 'Server name is required' });
}
- const parsedConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+ const configServers = await resolveConfigServers(req);
+ const parsedConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ userId,
+ configServers,
+ );
if (!parsedConfig) {
return res.status(404).json({ message: 'MCP server not found' });
diff --git a/api/server/index.js b/api/server/index.js
index ba376ab335..4b919b1ceb 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -8,8 +8,8 @@ const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
-const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
+const { logger, runAsSystem } = require('@librechat/data-schemas');
const {
isEnabled,
apiNotFound,
@@ -21,6 +21,7 @@ const {
createStreamServices,
initializeFileStorage,
updateInterfacePermissions,
+ preAuthTenantMiddleware,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
@@ -59,11 +60,20 @@ const startServer = async () => {
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
- await seedDatabase();
- const appConfig = await getAppConfig();
+ if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
+ logger.warn(
+ '[Security] TENANT_ISOLATION_STRICT is active. Ensure your reverse proxy strips or sets ' +
+ 'the X-Tenant-Id header — untrusted clients must not be able to set it directly.',
+ );
+ }
+
+ await runAsSystem(seedDatabase);
+ const appConfig = await getAppConfig({ baseOnly: true });
initializeFileStorage(appConfig);
- await performStartupChecks(appConfig);
- await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions });
+ await runAsSystem(async () => {
+ await performStartupChecks(appConfig);
+ await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions });
+ });
const indexPath = path.join(appConfig.paths.dist, 'index.html');
let indexHTML = fs.readFileSync(indexPath, 'utf8');
@@ -137,10 +147,15 @@ const startServer = async () => {
/* Per-request capability cache — must be registered before any route that calls hasCapability */
app.use(capabilityContextMiddleware);
- app.use('/oauth', routes.oauth);
+ /* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
+ * The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
+ app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
/* API Endpoints */
- app.use('/api/auth', routes.auth);
+ app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', routes.adminAuth);
+ app.use('/api/admin/config', routes.adminConfig);
+ app.use('/api/admin/groups', routes.adminGroups);
+ app.use('/api/admin/roles', routes.adminRoles);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
@@ -154,11 +169,11 @@ const startServer = async () => {
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
- app.use('/api/config', routes.config);
+ app.use('/api/config', preAuthTenantMiddleware, routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
- app.use('/api/share', routes.share);
+ app.use('/api/share', preAuthTenantMiddleware, routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
@@ -204,8 +219,10 @@ const startServer = async () => {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
- await initializeMCPs();
- await initializeOAuthReconnectManager();
+ await runAsSystem(async () => {
+ await initializeMCPs();
+ await initializeOAuthReconnectManager();
+ });
await checkMigrations();
// Configure stream services (auto-detects Redis from USE_REDIS env var)
diff --git a/api/server/middleware/__tests__/requireJwtAuth.spec.js b/api/server/middleware/__tests__/requireJwtAuth.spec.js
new file mode 100644
index 0000000000..bc288e5dab
--- /dev/null
+++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js
@@ -0,0 +1,116 @@
+/**
+ * Integration test: verifies that requireJwtAuth chains tenantContextMiddleware
+ * after successful passport authentication, so ALS tenant context is set for
+ * all downstream middleware and route handlers.
+ *
+ * requireJwtAuth must chain tenantContextMiddleware after passport populates
+ * req.user (not at global app.use() scope where req.user is undefined).
+ * If the chaining is removed, these tests fail.
+ */
+
+const { getTenantId } = require('@librechat/data-schemas');
+
+// ── Mocks ──────────────────────────────────────────────────────────────
+
+let mockPassportError = null;
+
+jest.mock('passport', () => ({
+ authenticate: jest.fn(() => {
+ return (req, _res, done) => {
+ if (mockPassportError) {
+ return done(mockPassportError);
+ }
+ if (req._mockUser) {
+ req.user = req._mockUser;
+ }
+ done();
+ };
+ }),
+}));
+
+// Mock @librechat/api — the real tenantContextMiddleware is TS and cannot be
+// required directly from CJS tests. This thin wrapper mirrors the real logic
+// (read req.user.tenantId, call tenantStorage.run) using the same data-schemas
+// primitives. The real implementation is covered by packages/api tenant.spec.ts.
+jest.mock('@librechat/api', () => {
+ const { tenantStorage } = require('@librechat/data-schemas');
+ return {
+ isEnabled: jest.fn(() => false),
+ tenantContextMiddleware: (req, res, next) => {
+ const tenantId = req.user?.tenantId;
+ if (!tenantId) {
+ return next();
+ }
+ return tenantStorage.run({ tenantId }, async () => next());
+ },
+ };
+});
+
+// ── Helpers ─────────────────────────────────────────────────────────────
+
+const requireJwtAuth = require('../requireJwtAuth');
+
+function mockReq(user) {
+ return { headers: {}, _mockUser: user };
+}
+
+function mockRes() {
+ return { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
+}
+
+/** Runs requireJwtAuth and returns the tenantId observed inside next(). */
+function runAuth(user) {
+ return new Promise((resolve) => {
+ const req = mockReq(user);
+ const res = mockRes();
+ requireJwtAuth(req, res, () => {
+ resolve(getTenantId());
+ });
+ });
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────
+
+describe('requireJwtAuth tenant context chaining', () => {
+ afterEach(() => {
+ mockPassportError = null;
+ });
+
+ it('forwards passport errors to next() without entering tenant middleware', async () => {
+ mockPassportError = new Error('JWT signature invalid');
+ const req = mockReq(undefined);
+ const res = mockRes();
+ const err = await new Promise((resolve) => {
+ requireJwtAuth(req, res, (e) => resolve(e));
+ });
+ expect(err).toBeInstanceOf(Error);
+ expect(err.message).toBe('JWT signature invalid');
+ expect(getTenantId()).toBeUndefined();
+ });
+
+ it('sets ALS tenant context after passport auth succeeds', async () => {
+ const tenantId = await runAuth({ tenantId: 'tenant-abc', role: 'user' });
+ expect(tenantId).toBe('tenant-abc');
+ });
+
+ it('ALS tenant context is NOT set when user has no tenantId', async () => {
+ const tenantId = await runAuth({ role: 'user' });
+ expect(tenantId).toBeUndefined();
+ });
+
+ it('ALS tenant context is NOT set when user is undefined', async () => {
+ const tenantId = await runAuth(undefined);
+ expect(tenantId).toBeUndefined();
+ });
+
+ it('concurrent requests get isolated tenant contexts', async () => {
+ const results = await Promise.all(
+ ['tenant-1', 'tenant-2', 'tenant-3'].map((tid) => runAuth({ tenantId: tid, role: 'user' })),
+ );
+ expect(results).toEqual(['tenant-1', 'tenant-2', 'tenant-3']);
+ });
+
+ it('ALS context is not set at top-level scope (outside any request)', () => {
+ expect(getTenantId()).toBeUndefined();
+ });
+});
diff --git a/api/server/middleware/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js
index 754eb9c127..f7a3f00e68 100644
--- a/api/server/middleware/checkDomainAllowed.js
+++ b/api/server/middleware/checkDomainAllowed.js
@@ -18,6 +18,7 @@ const checkDomainAllowed = async (req, res, next) => {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
+ tenantId: req?.user?.tenantId,
});
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js
index bca3c8f71d..fb5f89b229 100644
--- a/api/server/middleware/config/app.js
+++ b/api/server/middleware/config/app.js
@@ -4,7 +4,9 @@ const { getAppConfig } = require('~/server/services/Config');
const configMiddleware = async (req, res, next) => {
try {
const userRole = req.user?.role;
- req.config = await getAppConfig({ role: userRole });
+ const userId = req.user?.id;
+ const tenantId = req.user?.tenantId;
+ req.config = await getAppConfig({ role: userRole, userId, tenantId });
next();
} catch (error) {
diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js
index 2f59fdda4a..d46478d36e 100644
--- a/api/server/middleware/optionalJwtAuth.js
+++ b/api/server/middleware/optionalJwtAuth.js
@@ -1,9 +1,10 @@
const cookies = require('cookie');
const passport = require('passport');
-const { isEnabled } = require('@librechat/api');
+const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
// This middleware does not require authentication,
-// but if the user is authenticated, it will set the user object.
+// but if the user is authenticated, it will set the user object
+// and establish tenant ALS context.
const optionalJwtAuth = (req, res, next) => {
const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
@@ -13,6 +14,7 @@ const optionalJwtAuth = (req, res, next) => {
}
if (user) {
req.user = user;
+ return tenantContextMiddleware(req, res, next);
}
next();
};
diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js
index 16b107aefc..b13e991b23 100644
--- a/api/server/middleware/requireJwtAuth.js
+++ b/api/server/middleware/requireJwtAuth.js
@@ -1,20 +1,29 @@
const cookies = require('cookie');
const passport = require('passport');
-const { isEnabled } = require('@librechat/api');
+const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
/**
- * Custom Middleware to handle JWT authentication, with support for OpenID token reuse
- * Switches between JWT and OpenID authentication based on cookies and environment settings
+ * Custom Middleware to handle JWT authentication, with support for OpenID token reuse.
+ * Switches between JWT and OpenID authentication based on cookies and environment settings.
+ *
+ * After successful authentication (req.user populated), automatically chains into
+ * `tenantContextMiddleware` to propagate `req.user.tenantId` into AsyncLocalStorage
+ * for downstream Mongoose tenant isolation.
*/
const requireJwtAuth = (req, res, next) => {
const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
- if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
- return passport.authenticate('openidJwt', { session: false })(req, res, next);
- }
+ const strategy =
+ tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) ? 'openidJwt' : 'jwt';
- return passport.authenticate('jwt', { session: false })(req, res, next);
+ passport.authenticate(strategy, { session: false })(req, res, (err) => {
+ if (err) {
+ return next(err);
+ }
+ // req.user is now populated by passport — set up tenant ALS context
+ tenantContextMiddleware(req, res, next);
+ });
};
module.exports = requireJwtAuth;
diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js
index 1ad8cac087..f194f361d3 100644
--- a/api/server/routes/__tests__/mcp.spec.js
+++ b/api/server/routes/__tests__/mcp.spec.js
@@ -18,6 +18,7 @@ const mockRegistryInstance = {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
getAllServerConfigs: jest.fn(),
+ ensureConfigServers: jest.fn().mockResolvedValue({}),
addServer: jest.fn(),
updateServer: jest.fn(),
removeServer: jest.fn(),
@@ -58,6 +59,7 @@ jest.mock('@librechat/api', () => {
});
jest.mock('@librechat/data-schemas', () => ({
+ getTenantId: jest.fn(),
logger: {
debug: jest.fn(),
info: jest.fn(),
@@ -93,14 +95,18 @@ jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
loadCustomConfig: jest.fn(),
+ getAppConfig: jest.fn().mockResolvedValue({ mcpConfig: {} }),
}));
jest.mock('~/server/services/Config/mcp', () => ({
updateMCPServerTools: jest.fn(),
}));
+const mockResolveAllMcpConfigs = jest.fn().mockResolvedValue({});
jest.mock('~/server/services/MCP', () => ({
getMCPSetupData: jest.fn(),
+ resolveConfigServers: jest.fn().mockResolvedValue({}),
+ resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
getServerConnectionStatus: jest.fn(),
}));
@@ -579,6 +585,112 @@ describe('MCP Routes', () => {
);
});
+ it('should use oauthHeaders from flow state when present', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ oauthHeaders: { 'X-Custom-Auth': 'header-value' },
+ };
+ const mockTokens = { access_token: 'tok', refresh_token: 'ref' };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({ code: 'auth-code', state: flowId });
+
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ flowId,
+ 'auth-code',
+ mockFlowManager,
+ { 'X-Custom-Auth': 'header-value' },
+ );
+ expect(mockRegistryInstance.getServerConfig).not.toHaveBeenCalled();
+ });
+
+ it('should fall back to registry oauth_headers when flow state lacks them', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = { access_token: 'tok', refresh_token: 'ref' };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ oauth_headers: { 'X-Registry-Header': 'from-registry' },
+ });
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ require('~/config').getOAuthReconnectionManager.mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+ require('~/config').getMCPManager.mockReturnValue({
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ });
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const flowId = 'test-user-id:test-server';
+ const csrfToken = generateTestCsrfToken(flowId);
+
+ await request(app)
+ .get('/api/mcp/test-server/oauth/callback')
+ .set('Cookie', [`oauth_csrf=${csrfToken}`])
+ .query({ code: 'auth-code', state: flowId });
+
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ flowId,
+ 'auth-code',
+ mockFlowManager,
+ { 'X-Registry-Header': 'from-registry' },
+ );
+ expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
+ 'test-server',
+ 'test-user-id',
+ undefined,
+ );
+ });
+
it('should redirect to error page when callback processing fails', async () => {
MCPOAuthHandler.getFlowState.mockRejectedValue(new Error('Callback error'));
const flowId = 'test-user-id:test-server';
@@ -1350,19 +1462,10 @@ describe('MCP Routes', () => {
},
});
- expect(getMCPSetupData).toHaveBeenCalledWith('test-user-id');
+ expect(getMCPSetupData).toHaveBeenCalledWith('test-user-id', expect.any(Object));
expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
});
- it('should return 404 when MCP config is not found', async () => {
- getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
-
- const response = await request(app).get('/api/mcp/connection/status');
-
- expect(response.status).toBe(404);
- expect(response.body).toEqual({ error: 'MCP config not found' });
- });
-
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database error'));
@@ -1437,15 +1540,6 @@ describe('MCP Routes', () => {
});
});
- it('should return 404 when MCP config is not found', async () => {
- getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
-
- const response = await request(app).get('/api/mcp/connection/status/test-server');
-
- expect(response.status).toBe(404);
- expect(response.body).toEqual({ error: 'MCP config not found' });
- });
-
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
@@ -1704,7 +1798,7 @@ describe('MCP Routes', () => {
},
};
- mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
+ mockResolveAllMcpConfigs.mockResolvedValue(mockServerConfigs);
const response = await request(app).get('/api/mcp/servers');
@@ -1721,11 +1815,14 @@ describe('MCP Routes', () => {
});
expect(response.body['server-1'].headers).toBeUndefined();
expect(response.body['server-2'].headers).toBeUndefined();
- expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
+ expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith(
+ 'test-user-id',
+ expect.objectContaining({ id: 'test-user-id' }),
+ );
});
it('should return empty object when no servers are configured', async () => {
- mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
+ mockResolveAllMcpConfigs.mockResolvedValue({});
const response = await request(app).get('/api/mcp/servers');
@@ -1749,7 +1846,7 @@ describe('MCP Routes', () => {
});
it('should return 500 when server config retrieval fails', async () => {
- mockRegistryInstance.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
+ mockResolveAllMcpConfigs.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/servers');
@@ -1939,11 +2036,12 @@ describe('MCP Routes', () => {
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
'test-server',
'test-user-id',
+ {},
);
});
it('should return 404 when server not found', async () => {
- mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+ mockRegistryInstance.getServerConfig.mockResolvedValue(undefined);
const response = await request(app).get('/api/mcp/servers/non-existent-server');
diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js
new file mode 100644
index 0000000000..0632077ea9
--- /dev/null
+++ b/api/server/routes/admin/config.js
@@ -0,0 +1,40 @@
+const express = require('express');
+const { createAdminConfigHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const {
+ hasConfigCapability,
+ requireCapability,
+} = require('~/server/middleware/roles/capabilities');
+const { getAppConfig, invalidateConfigCaches } = require('~/server/services/Config');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+
+const handlers = createAdminConfigHandlers({
+ listAllConfigs: db.listAllConfigs,
+ findConfigByPrincipal: db.findConfigByPrincipal,
+ upsertConfig: db.upsertConfig,
+ patchConfigFields: db.patchConfigFields,
+ unsetConfigField: db.unsetConfigField,
+ deleteConfig: db.deleteConfig,
+ toggleConfigActive: db.toggleConfigActive,
+ hasConfigCapability,
+ getAppConfig,
+ invalidateConfigCaches,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', handlers.listConfigs);
+router.get('/base', handlers.getBaseConfig);
+router.get('/:principalType/:principalId', handlers.getConfig);
+router.put('/:principalType/:principalId', handlers.upsertConfigOverrides);
+router.patch('/:principalType/:principalId/fields', handlers.patchConfigField);
+router.delete('/:principalType/:principalId/fields', handlers.deleteConfigField);
+router.delete('/:principalType/:principalId', handlers.deleteConfigOverrides);
+router.patch('/:principalType/:principalId/active', handlers.toggleConfig);
+
+module.exports = router;
diff --git a/api/server/routes/admin/groups.js b/api/server/routes/admin/groups.js
new file mode 100644
index 0000000000..7ca93acaa2
--- /dev/null
+++ b/api/server/routes/admin/groups.js
@@ -0,0 +1,41 @@
+const express = require('express');
+const { createAdminGroupsHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireReadGroups = requireCapability(SystemCapabilities.READ_GROUPS);
+const requireManageGroups = requireCapability(SystemCapabilities.MANAGE_GROUPS);
+
+const handlers = createAdminGroupsHandlers({
+ listGroups: db.listGroups,
+ countGroups: db.countGroups,
+ findGroupById: db.findGroupById,
+ createGroup: db.createGroup,
+ updateGroupById: db.updateGroupById,
+ deleteGroup: db.deleteGroup,
+ addUserToGroup: db.addUserToGroup,
+ removeUserFromGroup: db.removeUserFromGroup,
+ removeMemberById: db.removeMemberById,
+ findUsers: db.findUsers,
+ deleteConfig: db.deleteConfig,
+ deleteAclEntries: db.deleteAclEntries,
+ deleteGrantsForPrincipal: db.deleteGrantsForPrincipal,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', requireReadGroups, handlers.listGroups);
+router.post('/', requireManageGroups, handlers.createGroup);
+router.get('/:id', requireReadGroups, handlers.getGroup);
+router.patch('/:id', requireManageGroups, handlers.updateGroup);
+router.delete('/:id', requireManageGroups, handlers.deleteGroup);
+router.get('/:id/members', requireReadGroups, handlers.getGroupMembers);
+router.post('/:id/members', requireManageGroups, handlers.addGroupMember);
+router.delete('/:id/members/:userId', requireManageGroups, handlers.removeGroupMember);
+
+module.exports = router;
diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js
new file mode 100644
index 0000000000..2d0f1b1128
--- /dev/null
+++ b/api/server/routes/admin/roles.js
@@ -0,0 +1,43 @@
+const express = require('express');
+const { createAdminRolesHandlers } = require('@librechat/api');
+const { SystemCapabilities } = require('@librechat/data-schemas');
+const { requireCapability } = require('~/server/middleware/roles/capabilities');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireReadRoles = requireCapability(SystemCapabilities.READ_ROLES);
+const requireManageRoles = requireCapability(SystemCapabilities.MANAGE_ROLES);
+
+const handlers = createAdminRolesHandlers({
+ listRoles: db.listRoles,
+ countRoles: db.countRoles,
+ getRoleByName: db.getRoleByName,
+ createRoleByName: db.createRoleByName,
+ updateRoleByName: db.updateRoleByName,
+ updateAccessPermissions: db.updateAccessPermissions,
+ deleteRoleByName: db.deleteRoleByName,
+ findUser: db.findUser,
+ updateUser: db.updateUser,
+ updateUsersByRole: db.updateUsersByRole,
+ findUserIdsByRole: db.findUserIdsByRole,
+ updateUsersRoleByIds: db.updateUsersRoleByIds,
+ listUsersByRole: db.listUsersByRole,
+ countUsersByRole: db.countUsersByRole,
+});
+
+router.use(requireJwtAuth, requireAdminAccess);
+
+router.get('/', requireReadRoles, handlers.listRoles);
+router.post('/', requireManageRoles, handlers.createRole);
+router.get('/:name', requireReadRoles, handlers.getRole);
+router.patch('/:name', requireManageRoles, handlers.updateRole);
+router.delete('/:name', requireManageRoles, handlers.deleteRole);
+router.patch('/:name/permissions', requireManageRoles, handlers.updateRolePermissions);
+router.get('/:name/members', requireReadRoles, handlers.getRoleMembers);
+router.post('/:name/members', requireManageRoles, handlers.addRoleMember);
+router.delete('/:name/members/:userId', requireManageRoles, handlers.removeRoleMember);
+
+module.exports = router;
diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js
new file mode 100644
index 0000000000..1f89953186
--- /dev/null
+++ b/api/server/routes/agents/__tests__/streamTenant.spec.js
@@ -0,0 +1,186 @@
+const express = require('express');
+const request = require('supertest');
+
+const mockGenerationJobManager = {
+ getJob: jest.fn(),
+ subscribe: jest.fn(),
+ getResumeState: jest.fn(),
+ abortJob: jest.fn(),
+ getActiveJobIdsForUser: jest.fn().mockResolvedValue([]),
+};
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn().mockReturnValue(false),
+ GenerationJobManager: mockGenerationJobManager,
+}));
+
+jest.mock('~/models', () => ({
+ saveMessage: jest.fn(),
+}));
+
+let mockUserId = 'user-123';
+let mockTenantId;
+
+jest.mock('~/server/middleware', () => ({
+ uaParser: (req, res, next) => next(),
+ checkBan: (req, res, next) => next(),
+ requireJwtAuth: (req, res, next) => {
+ req.user = { id: mockUserId, tenantId: mockTenantId };
+ next();
+ },
+ messageIpLimiter: (req, res, next) => next(),
+ configMiddleware: (req, res, next) => next(),
+ messageUserLimiter: (req, res, next) => next(),
+}));
+
+jest.mock('~/server/routes/agents/chat', () => require('express').Router());
+jest.mock('~/server/routes/agents/v1', () => ({
+ v1: require('express').Router(),
+}));
+jest.mock('~/server/routes/agents/openai', () => require('express').Router());
+jest.mock('~/server/routes/agents/responses', () => require('express').Router());
+
+const agentsRouter = require('../index');
+const app = express();
+app.use(express.json());
+app.use('/agents', agentsRouter);
+
+function mockSubscribeSuccess() {
+ mockGenerationJobManager.subscribe.mockImplementation((_streamId, _writeEvent, onDone) => {
+ process.nextTick(() => onDone({ done: true }));
+ return { unsubscribe: jest.fn() };
+ });
+}
+
+describe('SSE stream tenant isolation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+ });
+
+ describe('GET /chat/stream/:streamId', () => {
+ it('returns 403 when a user from a different tenant accesses a stream', async () => {
+ mockUserId = 'user-456';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-456', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+
+ it('returns 404 when stream does not exist', async () => {
+ mockGenerationJobManager.getJob.mockResolvedValue(null);
+
+ const res = await request(app).get('/agents/chat/stream/nonexistent');
+ expect(res.status).toBe(404);
+ });
+
+ it('proceeds past tenant guard when tenant matches', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-a';
+ mockSubscribeSuccess();
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('proceeds past tenant guard when job has no tenantId (single-tenant mode)', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+ mockSubscribeSuccess();
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(200);
+ expect(mockGenerationJobManager.subscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns 403 when job has tenantId but user has no tenantId', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = undefined;
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'some-tenant' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/stream/stream-123');
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('GET /chat/status/:conversationId', () => {
+ it('returns 403 when tenant does not match', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).get('/agents/chat/status/conv-123');
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+
+ it('returns status when tenant matches', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-a';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ createdAt: Date.now(),
+ });
+ mockGenerationJobManager.getResumeState.mockResolvedValue(null);
+
+ const res = await request(app).get('/agents/chat/status/conv-123');
+ expect(res.status).toBe(200);
+ expect(res.body.active).toBe(true);
+ });
+ });
+
+ describe('POST /chat/abort', () => {
+ it('returns 403 when tenant does not match', async () => {
+ mockUserId = 'user-123';
+ mockTenantId = 'tenant-b';
+
+ mockGenerationJobManager.getJob.mockResolvedValue({
+ metadata: { userId: 'user-123', tenantId: 'tenant-a' },
+ status: 'running',
+ });
+
+ const res = await request(app).post('/agents/chat/abort').send({ streamId: 'stream-123' });
+ expect(res.status).toBe(403);
+ expect(res.body.error).toBe('Unauthorized');
+ });
+ });
+});
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
index 86966a3f3e..eb42046bed 100644
--- a/api/server/routes/agents/index.js
+++ b/api/server/routes/agents/index.js
@@ -17,6 +17,11 @@ const chat = require('./chat');
const { LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
+/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
+function hasTenantMismatch(job, user) {
+ return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
+}
+
const router = express.Router();
/**
@@ -67,6 +72,10 @@ router.get('/chat/stream/:streamId', async (req, res) => {
return res.status(403).json({ error: 'Unauthorized' });
}
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
res.setHeader('Content-Encoding', 'identity');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -150,7 +159,10 @@ router.get('/chat/stream/:streamId', async (req, res) => {
* @returns { activeJobIds: string[] }
*/
router.get('/chat/active', async (req, res) => {
- const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(req.user.id);
+ const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(
+ req.user.id,
+ req.user.tenantId,
+ );
res.json({ activeJobIds });
});
@@ -174,6 +186,10 @@ router.get('/chat/status/:conversationId', async (req, res) => {
return res.status(403).json({ error: 'Unauthorized' });
}
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
// Get resume state which contains aggregatedContent
// Avoid calling both getStreamInfo and getResumeState (both fetch content)
const resumeState = await GenerationJobManager.getResumeState(conversationId);
@@ -213,7 +229,10 @@ router.post('/chat/abort', async (req, res) => {
// This handles the case where frontend sends "new" but job was created with a UUID
if (!job && userId) {
logger.debug(`[AgentStream] Job not found by ID, checking active jobs for user: ${userId}`);
- const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(userId);
+ const activeJobIds = await GenerationJobManager.getActiveJobIdsForUser(
+ userId,
+ req.user.tenantId,
+ );
if (activeJobIds.length > 0) {
// Abort the most recent active job for this user
jobStreamId = activeJobIds[0];
@@ -230,6 +249,10 @@ router.post('/chat/abort', async (req, res) => {
return res.status(403).json({ error: 'Unauthorized' });
}
+ if (hasTenantMismatch(job, req.user)) {
+ return res.status(403).json({ error: 'Unauthorized' });
+ }
+
logger.debug(`[AgentStream] Job found, aborting: ${jobStreamId}`);
const abortResult = await GenerationJobManager.abortJob(jobStreamId);
logger.debug(`[AgentStream] Job aborted successfully: ${jobStreamId}`, {
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index bf60f57e08..8caa180854 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -1,7 +1,7 @@
const express = require('express');
-const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
+const { logger, getTenantId, scopedCacheKey } = require('@librechat/data-schemas');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
const { getLogStores } = require('~/cache');
@@ -23,7 +23,8 @@ const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
- const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
+ const cacheKey = scopedCacheKey(CacheKeys.STARTUP_CONFIG);
+ const cachedStartupConfig = await cache.get(cacheKey);
if (cachedStartupConfig) {
res.send(cachedStartupConfig);
return;
@@ -37,7 +38,10 @@ router.get('/', async function (req, res) {
const ldap = getLdapConfig();
try {
- const appConfig = await getAppConfig({ role: req.user?.role });
+ const appConfig = await getAppConfig({
+ role: req.user?.role,
+ tenantId: req.user?.tenantId || getTenantId(),
+ });
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
@@ -141,7 +145,7 @@ router.get('/', async function (req, res) {
payload.customFooter = process.env.CUSTOM_FOOTER;
}
- await cache.set(CacheKeys.STARTUP_CONFIG, payload);
+ await cache.set(cacheKey, payload);
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
index 6a48919db3..71ae041fc2 100644
--- a/api/server/routes/index.js
+++ b/api/server/routes/index.js
@@ -2,6 +2,9 @@ const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const adminAuth = require('./admin/auth');
+const adminConfig = require('./admin/config');
+const adminGroups = require('./admin/groups');
+const adminRoles = require('./admin/roles');
const endpoints = require('./endpoints');
const staticRoute = require('./static');
const messages = require('./messages');
@@ -31,6 +34,9 @@ module.exports = {
mcp,
auth,
adminAuth,
+ adminConfig,
+ adminGroups,
+ adminRoles,
keys,
apiKeys,
user,
diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js
index d6d7ed5ea0..c6496ad4b4 100644
--- a/api/server/routes/mcp.js
+++ b/api/server/routes/mcp.js
@@ -1,5 +1,5 @@
const { Router } = require('express');
-const { logger } = require('@librechat/data-schemas');
+const { logger, getTenantId } = require('@librechat/data-schemas');
const {
CacheKeys,
Constants,
@@ -36,7 +36,11 @@ const {
getFlowStateManager,
getMCPManager,
} = require('~/config');
-const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+const {
+ getServerConnectionStatus,
+ resolveConfigServers,
+ getMCPSetupData,
+} = require('~/server/services/MCP');
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
@@ -101,7 +105,8 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
return res.status(400).json({ error: 'Invalid flow state' });
}
- const oauthHeaders = await getOAuthHeaders(serverName, userId);
+ const configServers = await resolveConfigServers(req);
+ const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
const {
authorizationUrl,
flowId: oauthFlowId,
@@ -233,7 +238,14 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
}
logger.debug('[MCP OAuth] Completing OAuth flow');
- const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
+ if (!flowState.oauthHeaders) {
+ logger.warn(
+ '[MCP OAuth] oauthHeaders absent from flow state — config-source server oauth_headers will be empty',
+ { serverName, flowId },
+ );
+ }
+ const oauthHeaders =
+ flowState.oauthHeaders ?? (await getOAuthHeaders(serverName, flowState.userId));
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
@@ -497,7 +509,12 @@ router.post(
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
const mcpManager = getMCPManager();
- const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
+ const configServers = await resolveConfigServers(req);
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ user.id,
+ configServers,
+ );
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
@@ -522,6 +539,8 @@ router.post(
const result = await reinitMCPServer({
user,
serverName,
+ serverConfig,
+ configServers,
userMCPAuthMap,
});
@@ -564,6 +583,7 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
+ { role: user.role, tenantId: getTenantId() },
);
const connectionStatus = {};
@@ -593,9 +613,6 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
connectionStatus,
});
} catch (error) {
- if (error.message === 'MCP config not found') {
- return res.status(404).json({ error: error.message });
- }
logger.error('[MCP Connection Status] Failed to get connection status', error);
res.status(500).json({ error: 'Failed to get connection status' });
}
@@ -616,6 +633,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
+ { role: user.role, tenantId: getTenantId() },
);
if (!mcpConfig[serverName]) {
@@ -640,9 +658,6 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
requiresOAuth: serverStatus.requiresOAuth,
});
} catch (error) {
- if (error.message === 'MCP config not found') {
- return res.status(404).json({ error: error.message });
- }
logger.error(
`[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
error,
@@ -664,7 +679,12 @@ router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, a
return res.status(401).json({ error: 'User not authenticated' });
}
- const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
+ const configServers = await resolveConfigServers(req);
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ user.id,
+ configServers,
+ );
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
@@ -703,8 +723,12 @@ router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, a
}
});
-async function getOAuthHeaders(serverName, userId) {
- const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+async function getOAuthHeaders(serverName, userId, configServers) {
+ const serverConfig = await getMCPServersRegistry().getServerConfig(
+ serverName,
+ userId,
+ configServers,
+ );
return serverConfig?.oauth_headers ?? {};
}
diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js
index ef50a365b9..816a0eac5b 100644
--- a/api/server/services/AuthService.js
+++ b/api/server/services/AuthService.js
@@ -13,6 +13,7 @@ const {
checkEmailConfig,
isEmailDomainAllowed,
shouldUseSecureCookie,
+ resolveAppConfigForUser,
} = require('@librechat/api');
const {
findUser,
@@ -189,7 +190,7 @@ const registerUser = async (user, additionalData = {}) => {
let newUserId;
try {
- const appConfig = await getAppConfig();
+ const appConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const errorMessage =
'The email address provided cannot be used. Please use a different email address.';
@@ -255,19 +256,52 @@ const registerUser = async (user, additionalData = {}) => {
};
/**
- * Request password reset
+ * Request password reset.
+ *
+ * Uses a two-phase domain check: fast-fail with the memory-cached base config
+ * (zero DB queries) to block globally denied domains before user lookup, then
+ * re-check with tenant-scoped config after user lookup so tenant-specific
+ * restrictions are enforced.
+ *
+ * Phase 1 (base check) returns an Error (HTTP 400) — this intentionally reveals
+ * that the domain is globally blocked, but fires before any DB lookup so it
+ * cannot confirm user existence. Phase 2 (tenant check) returns the generic
+ * success message (HTTP 200) to prevent user-enumeration via status codes.
+ *
* @param {ServerRequest} req
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
- const appConfig = await getAppConfig();
- if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+
+ const baseConfig = await getAppConfig({ baseOnly: true });
+ if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
+ logger.warn(
+ `[requestPasswordReset] Blocked - email domain not allowed [Email: ${email}] [IP: ${req.ip}]`,
+ );
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return error;
}
- const user = await findUser({ email }, 'email _id');
+
+ const user = await findUser({ email }, 'email _id role tenantId');
+ let appConfig = baseConfig;
+ if (user?.tenantId) {
+ try {
+ appConfig = await resolveAppConfigForUser(getAppConfig, user);
+ } catch (err) {
+ logger.error('[requestPasswordReset] Failed to resolve tenant config, using base:', err);
+ }
+ }
+
+ if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+ logger.warn(
+ `[requestPasswordReset] Tenant config blocked domain [Email: ${email}] [IP: ${req.ip}]`,
+ );
+ return {
+ message: 'If an account with that email exists, a password reset link has been sent to it.',
+ };
+ }
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);
diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js
index da78f8d775..c8abafdbe5 100644
--- a/api/server/services/AuthService.spec.js
+++ b/api/server/services/AuthService.spec.js
@@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
shouldUseSecureCookie: jest.fn(() => false),
+ resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
@@ -35,8 +36,14 @@ jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn()
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
-const { shouldUseSecureCookie } = require('@librechat/api');
-const { setOpenIDAuthTokens } = require('./AuthService');
+const {
+ shouldUseSecureCookie,
+ isEmailDomainAllowed,
+ resolveAppConfigForUser,
+} = require('@librechat/api');
+const { findUser } = require('~/models');
+const { getAppConfig } = require('~/server/services/Config');
+const { setOpenIDAuthTokens, requestPasswordReset } = require('./AuthService');
/** Helper to build a mock Express response */
function mockResponse() {
@@ -267,3 +274,68 @@ describe('setOpenIDAuthTokens', () => {
});
});
});
+
+describe('requestPasswordReset', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ isEmailDomainAllowed.mockReturnValue(true);
+ getAppConfig.mockResolvedValue({
+ registration: { allowedDomains: ['example.com'] },
+ });
+ resolveAppConfigForUser.mockResolvedValue({
+ registration: { allowedDomains: ['example.com'] },
+ });
+ });
+
+ it('should fast-fail with base config before DB lookup for blocked domains', async () => {
+ isEmailDomainAllowed.mockReturnValue(false);
+
+ const req = { body: { email: 'blocked@evil.com' }, ip: '127.0.0.1' };
+ const result = await requestPasswordReset(req);
+
+ expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
+ expect(findUser).not.toHaveBeenCalled();
+ expect(result).toBeInstanceOf(Error);
+ });
+
+ it('should call resolveAppConfigForUser for tenant user', async () => {
+ const user = {
+ _id: 'user-tenant',
+ email: 'user@example.com',
+ tenantId: 'tenant-x',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(user);
+
+ const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
+ await requestPasswordReset(req);
+
+ expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, user);
+ });
+
+ it('should reuse baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => {
+ findUser.mockResolvedValue({ _id: 'user-no-tenant', email: 'user@example.com' });
+
+ const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
+ await requestPasswordReset(req);
+
+ expect(resolveAppConfigForUser).not.toHaveBeenCalled();
+ });
+
+ it('should return generic response when tenant config blocks the domain (non-enumerable)', async () => {
+ const user = {
+ _id: 'user-tenant',
+ email: 'user@example.com',
+ tenantId: 'tenant-x',
+ role: 'USER',
+ };
+ findUser.mockResolvedValue(user);
+ isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
+
+ const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
+ const result = await requestPasswordReset(req);
+
+ expect(result).not.toBeInstanceOf(Error);
+ expect(result.message).toContain('If an account with that email exists');
+ });
+});
diff --git a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js
new file mode 100644
index 0000000000..49e94bc081
--- /dev/null
+++ b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js
@@ -0,0 +1,139 @@
+// ── Mocks ──────────────────────────────────────────────────────────────
+
+const mockConfigStoreDelete = jest.fn().mockResolvedValue(true);
+const mockClearAppConfigCache = jest.fn().mockResolvedValue(undefined);
+const mockClearOverrideCache = jest.fn().mockResolvedValue(undefined);
+
+jest.mock('~/cache/getLogStores', () => {
+ return jest.fn(() => ({
+ delete: mockConfigStoreDelete,
+ }));
+});
+
+jest.mock('~/server/services/start/tools', () => ({
+ loadAndFormatTools: jest.fn(() => ({})),
+}));
+
+jest.mock('../loadCustomConfig', () => jest.fn().mockResolvedValue({}));
+
+jest.mock('@librechat/data-schemas', () => {
+ const actual = jest.requireActual('@librechat/data-schemas');
+ return { ...actual, AppService: jest.fn(() => ({ availableTools: {} })) };
+});
+
+jest.mock('~/models', () => ({
+ getApplicableConfigs: jest.fn().mockResolvedValue([]),
+ getUserPrincipals: jest.fn().mockResolvedValue([]),
+}));
+
+const mockInvalidateCachedTools = jest.fn().mockResolvedValue(undefined);
+jest.mock('../getCachedTools', () => ({
+ setCachedTools: jest.fn().mockResolvedValue(undefined),
+ invalidateCachedTools: mockInvalidateCachedTools,
+}));
+
+const mockClearMcpConfigCache = jest.fn().mockResolvedValue(undefined);
+jest.mock('@librechat/api', () => ({
+ createAppConfigService: jest.fn(() => ({
+ getAppConfig: jest.fn().mockResolvedValue({ availableTools: {} }),
+ clearAppConfigCache: mockClearAppConfigCache,
+ clearOverrideCache: mockClearOverrideCache,
+ })),
+ clearMcpConfigCache: mockClearMcpConfigCache,
+}));
+
+// ── Tests ──────────────────────────────────────────────────────────────
+
+const { CacheKeys } = require('librechat-data-provider');
+const { invalidateConfigCaches } = require('../app');
+
+describe('invalidateConfigCaches', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('clears all four caches', async () => {
+ await invalidateConfigCaches();
+
+ expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1);
+ expect(mockClearOverrideCache).toHaveBeenCalledTimes(1);
+ expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true });
+ expect(mockConfigStoreDelete).toHaveBeenCalledWith(CacheKeys.ENDPOINT_CONFIG);
+ });
+
+ it('passes tenantId through to clearOverrideCache', async () => {
+ await invalidateConfigCaches('tenant-a');
+
+ expect(mockClearOverrideCache).toHaveBeenCalledWith('tenant-a');
+ expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1);
+ expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true });
+ });
+
+ it('does not throw when CONFIG_STORE.delete fails', async () => {
+ mockConfigStoreDelete.mockRejectedValueOnce(new Error('store not found'));
+
+ await expect(invalidateConfigCaches()).resolves.not.toThrow();
+
+ // Other caches should still have been invalidated
+ expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1);
+ expect(mockClearOverrideCache).toHaveBeenCalledTimes(1);
+ expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true });
+ });
+
+ it('all operations run in parallel (not sequentially)', async () => {
+ const order = [];
+
+ mockClearAppConfigCache.mockImplementation(
+ () =>
+ new Promise((r) =>
+ setTimeout(() => {
+ order.push('base');
+ r();
+ }, 10),
+ ),
+ );
+ mockClearOverrideCache.mockImplementation(
+ () =>
+ new Promise((r) =>
+ setTimeout(() => {
+ order.push('override');
+ r();
+ }, 10),
+ ),
+ );
+ mockInvalidateCachedTools.mockImplementation(
+ () =>
+ new Promise((r) =>
+ setTimeout(() => {
+ order.push('tools');
+ r();
+ }, 10),
+ ),
+ );
+ mockConfigStoreDelete.mockImplementation(
+ () =>
+ new Promise((r) =>
+ setTimeout(() => {
+ order.push('endpoint');
+ r();
+ }, 10),
+ ),
+ );
+
+ await invalidateConfigCaches();
+
+ // All four should have been called (parallel execution via Promise.allSettled)
+ expect(order).toHaveLength(4);
+ expect(new Set(order)).toEqual(new Set(['base', 'override', 'tools', 'endpoint']));
+ });
+
+ it('resolves even when clearAppConfigCache throws (partial failure)', async () => {
+ mockClearAppConfigCache.mockRejectedValueOnce(new Error('cache connection lost'));
+
+ await expect(invalidateConfigCaches()).resolves.not.toThrow();
+
+ // Other caches should still have been invalidated despite the failure
+ expect(mockClearOverrideCache).toHaveBeenCalledTimes(1);
+ expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true });
+ });
+});
diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js
index 75a5cbe56d..3256732ec2 100644
--- a/api/server/services/Config/app.js
+++ b/api/server/services/Config/app.js
@@ -1,12 +1,12 @@
const { CacheKeys } = require('librechat-data-provider');
-const { logger, AppService } = require('@librechat/data-schemas');
+const { AppService, logger, scopedCacheKey } = require('@librechat/data-schemas');
+const { createAppConfigService, clearMcpConfigCache } = require('@librechat/api');
+const { setCachedTools, invalidateCachedTools } = require('./getCachedTools');
const { loadAndFormatTools } = require('~/server/services/start/tools');
const loadCustomConfig = require('./loadCustomConfig');
-const { setCachedTools } = require('./getCachedTools');
const getLogStores = require('~/cache/getLogStores');
const paths = require('~/config/paths');
-
-const BASE_CONFIG_KEY = '_BASE_';
+const db = require('~/models');
const loadBaseConfig = async () => {
/** @type {TCustomConfig} */
@@ -20,65 +20,67 @@ const loadBaseConfig = async () => {
return AppService({ config, paths, systemTools });
};
+const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfigService({
+ loadBaseConfig,
+ setCachedTools,
+ getCache: getLogStores,
+ cacheKeys: CacheKeys,
+ getApplicableConfigs: db.getApplicableConfigs,
+ getUserPrincipals: db.getUserPrincipals,
+});
+
/**
- * Get the app configuration based on user context
- * @param {Object} [options]
- * @param {string} [options.role] - User role for role-based config
- * @param {boolean} [options.refresh] - Force refresh the cache
- * @returns {Promise