Compare commits

..

8 Commits

Author SHA1 Message Date
Marco Beretta
f75010369c feat: add back recoil 2025-07-12 22:19:45 +02:00
Marco Beretta
dc9d219f3e fix: package; refactor: tsconfig 2025-07-12 22:19:05 +02:00
Marco Beretta
b0c5db6756 fix: cleanup 2025-07-12 22:16:48 +02:00
Marco Beretta
ff722366e9 feat: cleanup unused types from common/index.ts
- Remove 104 unused type exports from packages/client/src/common/index.ts
- Keep only 7 actually used exports (93% reduction)
- Add cleanup script with enhanced import pattern detection
- Support both named imports and namespace imports (* as t)
- Create automatic backups and comprehensive documentation
- Maintain type safety with build verification
- No breaking changes to existing code

Kept exports:
- TShowToast, Option, OptionWithIcon, DropdownValueSetter
- MentionOption, NotificationSeverity, MenuItemProps

Scripts: cleanup-common-types-safe.js, README-CLEANUP.md
2025-07-12 22:13:51 +02:00
Marco Beretta
4f06c159be fix build client package 2025-07-10 23:47:05 +02:00
Marco Beretta
e042e1500f feat: Add jotai as a peer dependency 2025-07-06 15:32:47 +02:00
Marco Beretta
0503f0f903 feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools 2025-07-06 12:32:21 +02:00
Marco Beretta
e4adfe771b feat: init @librechat/client 2025-07-05 22:49:28 +02:00
443 changed files with 23849 additions and 19767 deletions

View File

@@ -142,10 +142,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash,gemini-2.0-flash-lite
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@@ -453,8 +453,8 @@ OPENID_REUSE_TOKENS=
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
@@ -485,21 +485,6 @@ SAML_IMAGE_URL=
# SAML_USE_AUTHN_RESPONSE_SIGNED=
#===============================================#
# Microsoft Graph API / Entra ID Integration #
#===============================================#
# Enable Entra ID people search integration in permissions/sharing system
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
# LDAP
LDAP_URL=
LDAP_BIND_DN=
@@ -672,4 +657,4 @@ OPENWEATHER_API_KEY=
# Reranker (Required)
# JINA_API_KEY=your_jina_api_key
# or
# COHERE_API_KEY=your_cohere_api_key
# COHERE_API_KEY=your_cohere_api_key

3
.vscode/launch.json vendored
View File

@@ -8,8 +8,7 @@
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/server/index.js",
"env": {
"NODE_ENV": "production",
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
"NODE_ENV": "production"
},
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"

View File

@@ -1,7 +1,7 @@
const { google } = require('googleapis');
const { Tokenizer } = require('@librechat/api');
const { concat } = require('@langchain/core/utils/stream');
const { ChatVertexAI } = require('@langchain/google-vertexai');
const { Tokenizer, getSafetySettings } = require('@librechat/api');
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
const { GoogleGenerativeAI: GenAI } = require('@google/generative-ai');
const { HumanMessage, SystemMessage } = require('@langchain/core/messages');
@@ -12,13 +12,13 @@ const {
endpointSettings,
parseTextParts,
EModelEndpoint,
googleSettings,
ContentTypes,
VisionModes,
ErrorTypes,
Constants,
AuthKeys,
} = require('librechat-data-provider');
const { getSafetySettings } = require('~/server/services/Endpoints/google/llm');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { spendTokens } = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
@@ -166,16 +166,6 @@ class GoogleClient extends BaseClient {
);
}
// Add thinking configuration
this.modelOptions.thinkingConfig = {
thinkingBudget:
(this.modelOptions.thinking ?? googleSettings.thinking.default)
? this.modelOptions.thinkingBudget
: 0,
};
delete this.modelOptions.thinking;
delete this.modelOptions.thinkingBudget;
this.sender =
this.options.sender ??
getResponseSender({

View File

@@ -1,7 +1,6 @@
const axios = require('axios');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const footer = `Use the context as your learned knowledge to better answer the user.
@@ -19,7 +18,7 @@ function createContextHandlers(req, userMessageContent) {
const queryPromises = [];
const processedFiles = [];
const processedIds = new Set();
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = req.headers.authorization.split(' ')[1];
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
const query = async (file) => {

View File

@@ -1,10 +1,9 @@
const { z } = require('zod');
const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
/**
*
@@ -60,7 +59,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
}
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = req.headers.authorization.split(' ')[1];
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
}

View File

@@ -1,8 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, math } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('~/server/utils');
const { deleteAllUserSessions } = require('~/models');
const { removePorts } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};

View File

@@ -1,7 +1,7 @@
const { Keyv } = require('keyv');
const { isEnabled, math } = require('@librechat/api');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { isEnabled, math } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo');

View File

@@ -4,7 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
// Default category value for new agents
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
const {
getProjectByName,
addAgentIdsToProject,
@@ -12,9 +12,7 @@ const {
removeAgentFromAllProjects,
} = require('./Project');
const { getCachedTools } = require('~/server/services/Config');
// Category values are now imported from shared constants
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
const getLogStores = require('~/cache/getLogStores');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@@ -25,7 +23,7 @@ const { Agent } = require('~/db/models');
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
const { author: _author, ...versionData } = agentData;
const { author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
@@ -36,7 +34,6 @@ const createAgent = async (agentData) => {
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@@ -129,7 +126,29 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
agent.version = agent.versions ? agent.versions.length : 0;
return agent;
if (agent.author.toString() === req.user.id) {
return agent;
}
if (!agent.projectIds) {
return null;
}
const cache = getLogStores(CONFIG_STORE);
/** @type {TStartupConfig} */
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
let { instanceProjectId } = cachedStartupConfig ?? {};
if (!instanceProjectId) {
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
}
for (const projectObjectId of agent.projectIds) {
const projectId = projectObjectId.toString();
if (projectId === instanceProjectId) {
return agent;
}
}
};
/**
@@ -159,7 +178,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
'actionsHash', // Exclude actionsHash from direct comparison
];
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
return null;
@@ -178,116 +197,54 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
let isMatch = true;
for (const field of importantFields) {
const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
continue;
}
// Handle arrays
if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects
else if (
wouldBeArr.length > 0 &&
typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersionArr].sort();
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
}
// Handle objects
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
@@ -316,14 +273,7 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const {
__v,
_id,
id: __id,
versions,
author: _author,
...versionData
} = currentAgent.toObject();
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
let actionsHash = null;
@@ -514,113 +464,8 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgentsByAccess = async ({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) => {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible agents with other filters
const baseQuery = { ...otherParams };
if (accessibleIds.length > 0) {
baseQuery._id = { $in: accessibleIds };
}
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
let query = Agent.find(baseQuery, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
if (isPaginated) {
query = query.limit(normalizedLimit + 1);
}
const agents = await query.lean();
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastAgent = agents[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastAgent.updatedAt.toISOString(),
_id: lastAgent._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0].id : null,
last_id: data.length > 0 ? data[data.length - 1].id : null,
has_more: hasMore,
after: nextCursor,
};
};
/**
* Get all agents.
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
* @param {Object} searchParameter - The search parameters to find matching agents.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
@@ -639,15 +484,13 @@ const getListAgents = async (searchParameter) => {
const agents = (
await Agent.find(query, {
id: 1,
_id: 1,
_id: 0,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {
@@ -813,14 +656,6 @@ const generateActionMetadataHash = async (actionIds, actions) => {
return hashHex;
};
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/**
* Load a default agent based on the endpoint
@@ -838,8 +673,6 @@ module.exports = {
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
countPromotedAgents,
};

View File

@@ -1633,7 +1633,7 @@ describe('models/Agent', () => {
expect(result.version).toBe(1);
});
test('should return agent even when user is not author (permissions checked at route level)', async () => {
test('should return null when user is not author and agent has no projectIds', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1654,11 +1654,7 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
expect(result).toBeFalsy();
});
test('should handle ephemeral agent with no MCP servers', async () => {
@@ -1766,7 +1762,7 @@ describe('models/Agent', () => {
}
});
test('should return agent from different project (permissions checked at route level)', async () => {
test('should handle loadAgent with agent from different project', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1789,11 +1785,7 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Project Agent');
expect(result).toBeFalsy();
});
});
});

View File

@@ -1,6 +1,4 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');
@@ -100,15 +98,10 @@ module.exports = {
update.conversationId = newConversationId;
}
if (req?.body?.isTemporary) {
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = null;
}
if (req.body.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
} else {
update.expiredAt = null;
}

View File

@@ -1,7 +1,5 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();
@@ -56,14 +54,9 @@ async function saveMessage(req, params, metadata) {
};
if (req?.body?.isTemporary) {
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = null;
}
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
} else {
update.expiredAt = null;
}

View File

@@ -2,6 +2,7 @@ const {
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
permissionsSchema,
removeNullishValues,
} = require('librechat-data-provider');

View File

@@ -48,11 +48,10 @@
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.46",
"@librechat/agents": "^2.4.42",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",

View File

@@ -1,17 +1,17 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
requestPasswordReset,
setOpenIDAuthTokens,
registerUser,
resetPassword,
setAuthTokens,
registerUser,
requestPasswordReset,
setOpenIDAuthTokens,
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const registrationController = async (req, res) => {
try {

View File

@@ -1,5 +1,3 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getResponseSender } = require('librechat-data-provider');
const {
handleAbortError,
@@ -12,8 +10,9 @@ const {
clientRegistry,
requestDataMap,
} = require('~/server/cleanup');
const { createOnProgress } = require('~/server/utils');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const EditController = async (req, res, next, initializeClient) => {
let {
@@ -199,7 +198,7 @@ const EditController = async (req, res, next, initializeClient) => {
const finalUserMessage = reqDataContext.userMessage;
const finalResponseMessage = { ...response };
sendEvent(res, {
sendMessage(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -1,437 +0,0 @@
/**
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
*/
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
getAvailableRoles,
ensurePrincipalExists,
getEffectivePermissions,
ensureGroupPrincipalExists,
bulkUpdateResourcePermissions,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
calculateRelevanceScore,
} = require('~/models');
const {
searchEntraIdPrincipals,
entraIdPrincipalFeatureEnabled,
} = require('~/server/services/GraphApiService');
/**
* Generic controller for resource permission endpoints
* Delegates validation and logic to PermissionService
*/
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
* @param {string} req.params.resourceId - Resource ID
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
* @param {Object} res - Express response object
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
*/
const updateResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
}
// Add public permission if enabled
if (isPublic && publicAccessRoleId) {
updatedPrincipals.push({
type: 'public',
id: null,
accessRoleId: publicAccessRoleId,
});
}
// Prepare authentication context for enhanced group member fetching
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const authContext =
useEntraId && accessToken
? {
accessToken,
sub: req.user.openidId,
}
: null;
// Ensure updated principals exist in the database before processing permissions
const validatedPrincipals = [];
for (const principal of updatedPrincipals) {
try {
let principalId;
if (principal.type === 'public') {
principalId = null; // Public principals don't need database records
} else if (principal.type === 'user') {
principalId = await ensurePrincipalExists(principal);
} else if (principal.type === 'group') {
// Pass authContext to enable member fetching for Entra ID groups when available
principalId = await ensureGroupPrincipalExists(principal, authContext);
} else {
logger.error(`Unsupported principal type: ${principal.type}`);
continue; // Skip invalid principal types
}
// Update the principal with the validated ID for ACL operations
validatedPrincipals.push({
...principal,
id: principalId,
});
} catch (error) {
logger.error('Error ensuring principal exists:', {
principal: {
type: principal.type,
id: principal.id,
name: principal.name,
source: principal.source,
},
error: error.message,
});
// Continue with other principals instead of failing the entire operation
continue;
}
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
}
// If public is disabled, add public to revoked list
if (!isPublic) {
revokedPrincipals.push({
type: 'public',
id: null,
});
}
const results = await bulkUpdateResourcePermissions({
resourceType,
resourceId,
updatedPrincipals: validatedPrincipals,
revokedPrincipals,
grantedBy: userId,
});
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
public: isPublic || false,
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
error: 'Failed to update permissions',
details: error.message,
});
}
};
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
// Use aggregation pipeline for efficient single-query data retrieval
const results = await AclEntry.aggregate([
// Match ACL entries for this resource
{
$match: {
resourceType,
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
? mongoose.Types.ObjectId.createFromHexString(resourceId)
: resourceId,
},
},
// Lookup AccessRole information
{
$lookup: {
from: 'accessroles',
localField: 'roleId',
foreignField: '_id',
as: 'role',
},
},
// Lookup User information (for user principals)
{
$lookup: {
from: 'users',
localField: 'principalId',
foreignField: '_id',
as: 'userInfo',
},
},
// Lookup Group information (for group principals)
{
$lookup: {
from: 'groups',
localField: 'principalId',
foreignField: '_id',
as: 'groupInfo',
},
},
// Project final structure
{
$project: {
principalType: 1,
principalId: 1,
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
},
},
]);
const principals = [];
let publicPermission = null;
// Process aggregation results
for (const result of results) {
if (result.principalType === 'public') {
publicPermission = {
public: true,
publicAccessRoleId: result.accessRoleId,
};
} else if (result.principalType === 'user' && result.userInfo) {
principals.push({
type: 'user',
id: result.userInfo._id.toString(),
name: result.userInfo.name || result.userInfo.username,
email: result.userInfo.email,
avatar: result.userInfo.avatar,
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (result.principalType === 'group' && result.groupInfo) {
principals.push({
type: 'group',
id: result.groupInfo._id.toString(),
name: result.groupInfo.name,
email: result.groupInfo.email,
description: result.groupInfo.description,
avatar: result.groupInfo.avatar,
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
}
}
// Return response in format expected by frontend
const response = {
resourceType,
resourceId,
principals,
public: publicPermission?.public || false,
...(publicPermission?.publicAccessRoleId && {
publicAccessRoleId: publicPermission.publicAccessRoleId,
}),
};
res.status(200).json(response);
} catch (error) {
logger.error('Error getting resource permissions principals:', error);
res.status(500).json({
error: 'Failed to get permissions principals',
details: error.message,
});
}
};
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
const { resourceType } = req.params;
const roles = await getAvailableRoles({ resourceType });
res.status(200).json(
roles.map((role) => ({
accessRoleId: role.accessRoleId,
name: role.name,
description: role.description,
permBits: role.permBits,
})),
);
} catch (error) {
logger.error('Error getting resource roles:', error);
res.status(500).json({
error: 'Failed to get roles',
details: error.message,
});
}
};
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
const { id: userId } = req.user;
const permissionBits = await getEffectivePermissions({
userId,
resourceType,
resourceId,
});
res.status(200).json({
permissionBits,
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
res.status(500).json({
error: 'Failed to get effective permissions',
details: error.message,
});
}
};
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
const { q: query, limit = 20, type } = req.query;
if (!query || query.trim().length === 0) {
return res.status(400).json({
error: 'Query parameter "q" is required and must not be empty',
});
}
if (query.trim().length < 2) {
return res.status(400).json({
error: 'Query must be at least 2 characters long',
});
}
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
const typeFilter = ['user', 'group'].includes(type) ? type : null;
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
if (useEntraId && localResults.length < searchLimit) {
try {
const graphTypeMap = {
user: 'users',
group: 'groups',
null: 'all',
};
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (accessToken) {
const graphResults = await searchEntraIdPrincipals(
accessToken,
req.user.openidId,
query.trim(),
graphTypeMap[typeFilter],
searchLimit - localResults.length,
);
const localEmails = new Set(
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
);
const localGroupSourceIds = new Set(
localResults.map((p) => p.idOnTheSource).filter(Boolean),
);
for (const principal of graphResults) {
const isDuplicateByEmail =
principal.email && localEmails.has(principal.email.toLowerCase());
const isDuplicateBySourceId =
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
allPrincipals.push(principal);
}
}
}
} catch (graphError) {
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
}
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: calculateRelevanceScore(item, query.trim()),
}));
allPrincipals = sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;
return resultWithoutScore;
});
res.status(200).json({
query: query.trim(),
limit: searchLimit,
type: typeFilter,
results: allPrincipals,
count: allPrincipals.length,
sources: {
local: allPrincipals.filter((r) => r.source === 'local').length,
entra: allPrincipals.filter((r) => r.source === 'entra').length,
},
});
} catch (error) {
logger.error('Error searching principals:', error);
res.status(500).json({
error: 'Failed to search principals',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
searchPrincipals,
};

View File

@@ -4,7 +4,6 @@ const {
sendEvent,
createRun,
Tokenizer,
checkAccess,
memoryInstructions,
createMemoryProcessor,
} = require('@librechat/api');
@@ -40,22 +39,11 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints');
const { checkAccess } = require('~/server/middleware/roles/access');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
const omitTitleOptions = new Set([
'stream',
'thinking',
'streaming',
'clientOptions',
'thinkingConfig',
'thinkingBudget',
'includeThoughts',
'maxOutputTokens',
]);
/**
* @param {ServerRequest} req
* @param {Agent} agent
@@ -402,12 +390,7 @@ class AgentClient extends BaseClient {
if (user.personalization?.memories === false) {
return;
}
const hasAccess = await checkAccess({
user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE],
getRoleByName,
});
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]);
if (!hasAccess) {
logger.debug(
@@ -1055,16 +1038,6 @@ class AgentClient extends BaseClient {
delete clientOptions.maxTokens;
}
clientOptions = Object.assign(
Object.fromEntries(
Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),
),
);
if (provider === Providers.GOOGLE) {
clientOptions.json = true;
}
try {
const titleResult = await this.run.generateTitle({
provider,

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { logger } = require('@librechat/data-schemas');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendResponse } = require('~/server/utils');
/**
* @typedef {Object} ErrorHandlerContext
@@ -75,7 +75,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

@@ -1,5 +1,3 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const {
handleAbortError,
@@ -7,7 +5,9 @@ const {
cleanupAbortController,
} = require('~/server/middleware');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { sendMessage } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const AgentController = async (req, res, next, initializeClient, addTitle) => {
let {
@@ -206,7 +206,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a new response object with minimal copies
const finalResponse = { ...response };
sendEvent(res, {
sendMessage(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -1,10 +1,11 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const { logger } = require('@librechat/data-schemas');
const {
Tools,
SystemRoles,
Constants,
FileSources,
SystemRoles,
EToolResources,
actionDelimiter,
} = require('librechat-data-provider');
@@ -13,24 +14,18 @@ const {
createAgent,
updateAgent,
deleteAgent,
getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
getListAgents,
} = require('~/models/Agent');
const {
grantPermission,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { revertAgentVersion } = require('~/models/Agent');
const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
@@ -74,27 +69,6 @@ const createAgentHandler = async (req, res) => {
agentData.id = `agent_${nanoid()}`;
const agent = await createAgent(agentData);
// Automatically grant owner permissions to the creator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
} catch (permissionError) {
logger.error(
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
permissionError,
);
}
res.status(201).json(agent);
} catch (error) {
logger.error('[/Agents] Error creating agent', error);
@@ -113,14 +87,21 @@ const createAgentHandler = async (req, res) => {
* @returns {Promise<Agent>} 200 - success response - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentHandler = async (req, res, expandProperties = false) => {
const getAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const author = req.user.id;
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await getAgent({ id });
let query = { id, author };
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
query = {
$or: [{ id, $in: globalProject.agentIds }, query],
};
}
const agent = await getAgent(query);
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
@@ -137,45 +118,23 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
}
agent.author = agent.author.toString();
// @deprecated - isCollaborative replaced by ACL permissions
agent.isCollaborative = !!agent.isCollaborative;
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: 'agent',
resourceId: agent._id,
requiredPermissions: PermissionBits.VIEW,
});
agent.isPublic = isPublic;
if (agent.author !== author) {
delete agent.author;
}
if (!expandProperties) {
// VIEW permission: Basic agent info only
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
return res.status(200).json({
_id: agent._id,
id: agent.id,
name: agent.name,
description: agent.description,
avatar: agent.avatar,
author: agent.author,
provider: agent.provider,
model: agent.model,
projectIds: agent.projectIds,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: agent.isCollaborative,
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
});
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
@@ -195,20 +154,42 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { _id, ...updateData } = req.body;
const { projectIds, removeProjectIds, ...updateData } = req.body;
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
const isAuthor = existingAgent.author.toString() === req.user.id;
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
/** @type {boolean} */
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
skipVersioning: isProjectUpdate,
})
: existingAgent;
if (isProjectUpdate) {
updatedAgent = await updateAgentProjects({
user: req.user,
agentId: id,
projectIds,
removeProjectIds,
});
}
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
@@ -326,26 +307,6 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
const newAgent = await createAgent(newAgentData);
// Automatically grant owner permissions to the duplicator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: newAgent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
} catch (permissionError) {
logger.error(
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
permissionError,
);
}
return res.status(201).json({
agent: newAgent,
actions: newActionsList,
@@ -372,7 +333,7 @@ const deleteAgentHandler = async (req, res) => {
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await deleteAgent({ id });
await deleteAgent({ id, author: req.user.id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
@@ -381,7 +342,7 @@ const deleteAgentHandler = async (req, res) => {
};
/**
* Lists agents using ACL-aware permissions (ownership + explicit shares).
*
* @route GET /Agents
* @param {object} req - Express Request
* @param {object} req.query - Request query
@@ -390,64 +351,9 @@ const deleteAgentHandler = async (req, res) => {
*/
const getListAgentsHandler = async (req, res) => {
try {
const userId = req.user.id;
const { category, search, limit, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: requiredPermission,
const data = await getListAgents({
author: req.user.id,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
otherParams: filter,
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agent.isPublic = true;
}
return agent;
});
}
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents', error);
@@ -525,7 +431,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
};
promises.push(
await updateAgent({ id: agent_id }, data, {
await updateAgent({ id: agent_id, author: req.user.id }, data, {
updatingUserId: req.user.id,
}),
);
@@ -605,48 +511,7 @@ const revertAgentVersionHandler = async (req, res) => {
res.status(500).json({ error: error.message });
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@@ -656,5 +521,4 @@ module.exports = {
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
};

View File

@@ -1,7 +1,4 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -22,20 +19,20 @@ const {
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -474,7 +471,7 @@ const chatV1 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendEvent(res, {
sendMessage(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -590,7 +587,7 @@ const chatV1 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendEvent(res, {
sendMessage(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,7 +1,4 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -25,14 +22,15 @@ const { createErrorHandler } = require('~/server/controllers/assistants/errors')
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { sendMessage, sleep, countTokens } = require('~/server/utils');
const { createRunBody } = require('~/server/services/createRunBody');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -311,7 +309,7 @@ const chatV2 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendEvent(res, {
sendMessage(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -434,7 +432,7 @@ const chatV2 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendEvent(res, {
sendMessage(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const { getConvo } = require('~/models/Conversation');
const { sendResponse } = require('~/server/utils');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { getConvo } = require('~/models/Conversation');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
/**
* @typedef {Object} ErrorHandlerContext
@@ -78,7 +78,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

@@ -1,7 +1,5 @@
const { nanoid } = require('nanoid');
const { EnvVar } = require('@librechat/agents');
const { checkAccess } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Tools,
AuthType,
@@ -15,8 +13,9 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
const { getRoleByName } = require('~/models/Role');
const { checkAccess } = require('~/server/middleware');
const { getMessage } = require('~/models/Message');
const { logger } = require('~/config');
const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
@@ -80,7 +79,6 @@ const verifyToolAuth = async (req, res) => {
throwError: false,
});
} catch (error) {
logger.error('Error loading auth values', error);
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return;
}
@@ -134,12 +132,7 @@ const callTool = async (req, res) => {
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
hasAccess = await checkAccess(req.user, toolAccessPermType[toolId], [Permissions.USE]);
}
if (!hasAccess) {
logger.warn(

View File

@@ -118,8 +118,6 @@ const startServer = async () => {
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);

View File

@@ -1,13 +1,13 @@
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
// abortMiddleware.js
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { spendTokens } = require('~/models/spendTokens');
const abortControllers = require('./abortControllers');
const { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun');
const { logger } = require('~/config');
const abortDataMap = new WeakMap();
@@ -101,7 +101,7 @@ async function abortMessage(req, res) {
cleanupAbortController(abortKey);
if (res.headersSent && finalEvent) {
return sendEvent(res, finalEvent);
return sendMessage(res, finalEvent);
}
res.setHeader('Content-Type', 'application/json');
@@ -174,7 +174,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
* @param {string} responseMessageId
*/
const onStart = (userMessage, responseMessageId) => {
sendEvent(res, { message: userMessage, created: true });
sendMessage(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
getReqData({ abortKey });

View File

@@ -1,11 +1,11 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
const { logger } = require('~/config');
const three_minutes = 1000 * 60 * 3;
@@ -34,7 +34,7 @@ async function abortRun(req, res) {
const [thread_id, run_id] = runValues.split(':');
if (!run_id) {
logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
logger.warn('[abortRun] Couldn\'t find run for cancel request', { thread_id });
return res.status(204).send({ message: 'Run not found' });
} else if (run_id === 'cancelled') {
logger.warn('[abortRun] Run already cancelled', { thread_id });
@@ -93,7 +93,7 @@ async function abortRun(req, res) {
};
if (res.headersSent && finalEvent) {
return sendEvent(res, finalEvent);
return sendMessage(res, finalEvent);
}
res.json(finalEvent);

View File

@@ -1,97 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models/Agent');
/**
* Agent ID resolver function for agent_id from request body
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
* This is used specifically for chat routes where agent_id comes from request body
*
* @param {string} agentCustomId - Custom agent ID from request body
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentIdFromBody = async (agentCustomId) => {
// Handle ephemeral agents - they don't need permission checks
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
return null; // No permission check needed for ephemeral agents
}
return await getAgent({ id: agentCustomId });
};
/**
* Middleware factory that creates middleware to check agent access permissions from request body.
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
* instead of route parameters.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for agent chat (requires VIEW permission)
* router.post('/chat',
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
* buildEndpointOption,
* chatController
* );
*/
const canAccessAgentFromBody = (options) => {
const { requiredPermission } = options;
// Validate required options
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
const { endpoint, agent_id } = req.body;
let agentId = agent_id;
if (!isAgentsEndpoint(endpoint)) {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
return next();
}
const agentAccessMiddleware = canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
idResolver: () => resolveAgentIdFromBody(agentId),
});
const tempReq = {
...req,
params: {
...req.params,
agent_id: agentId,
},
};
return agentAccessMiddleware(tempReq, res, next);
} catch (error) {
logger.error('Failed to validate agent access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate agent access permissions',
});
}
};
};
module.exports = {
canAccessAgentFromBody,
};

View File

@@ -1,58 +0,0 @@
const { getAgent } = require('~/models/Agent');
const { canAccessResource } = require('./canAccessResource');
/**
* Agent ID resolver function
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
*
* @param {string} agentCustomId - Custom agent ID from route parameter
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentId = async (agentCustomId) => {
return await getAgent({ id: agentCustomId });
};
/**
* Agent-specific middleware factory that creates middleware to check agent access permissions.
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing agents
* router.get('/agents/:id',
* canAccessAgentResource({ requiredPermission: 1 }),
* getAgent
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/agents/:agent_id',
* canAccessAgentResource({
* requiredPermission: 2,
* resourceIdParam: 'agent_id'
* }),
* updateAgent
* );
*/
const canAccessAgentResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam,
idResolver: resolveAgentId,
});
};
module.exports = {
canAccessAgentResource,
};

View File

@@ -1,384 +0,0 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id.toString(), role: 'test-role' },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessAgentResource({})).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam', () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessAgentResource({
requiredPermission: 2,
resourceIdParam: 'agent_id',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real agents', () => {
test('should allow access when user is the agent author', async () => {
// Create an agent owned by the test user
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Other User Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: 'user',
principalId: otherUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Limited Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should handle non-existent agent', async () => {
req.params.id = 'agent_nonexistent';
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'agent not found',
});
});
test('should use custom resourceIdParam', async () => {
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Custom Param Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.agent_id = agent.id; // Using custom param name
const middleware = canAccessAgentResource({
requiredPermission: 1,
resourceIdParam: 'agent_id',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let agent;
beforeEach(async () => {
agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Permission Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
});
test('should support view permission (1)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with agent operations', () => {
test('should work with agent CRUD operations', async () => {
const agentId = `agent_${Date.now()}`;
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Integration Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
description: 'Testing integration',
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.id = agentId;
// Test view access
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -1,157 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
/**
* Generic base middleware factory that creates middleware to check resource access permissions.
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
*
* @param {Object} options - Configuration options
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
* @returns {Function} Express middleware function
*
* @example
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
* router.get('/prompts/:promptId',
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
* getPrompt
* );
*
* @example
* // Usage with custom ID resolver (for resources that use custom string IDs)
* router.get('/agents/:id',
* canAccessResource({
* resourceType: 'agent',
* requiredPermission: 1,
* resourceIdParam: 'id',
* idResolver: (customId) => resolveAgentId(customId)
* }),
* getAgent
* );
*/
const canAccessResource = (options) => {
const {
resourceType,
requiredPermission,
resourceIdParam = 'resourceId',
idResolver = null,
} = options;
if (!resourceType || typeof resourceType !== 'string') {
throw new Error('canAccessResource: resourceType is required and must be a string');
}
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessResource: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
// Extract resource ID from route parameters
const rawResourceId = req.params[resourceIdParam];
if (!rawResourceId) {
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
return res.status(400).json({
error: 'Bad Request',
message: `${resourceIdParam} is required`,
});
}
// Check if user is authenticated
if (!req.user || !req.user.id) {
logger.warn(
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// if system admin let through
if (req.user.role === SystemRoles.ADMIN) {
return next();
}
const userId = req.user.id;
let resourceId = rawResourceId;
let resourceInfo = null;
// Resolve custom ID to ObjectId if resolver is provided
if (idResolver) {
logger.debug(
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
);
const resolutionResult = await idResolver(rawResourceId);
if (!resolutionResult) {
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
return res.status(404).json({
error: 'Not Found',
message: `${resourceType} not found`,
});
}
// Handle different resolver return formats
if (typeof resolutionResult === 'string' || resolutionResult._id) {
resourceId = resolutionResult._id || resolutionResult;
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
} else {
resourceId = resolutionResult;
}
logger.debug(
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
);
}
// Check permissions using PermissionService with ObjectId
const hasPermission = await checkPermission({
userId,
resourceType,
resourceId,
requiredPermission,
});
if (hasPermission) {
logger.debug(
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
);
req.resourceAccess = {
resourceType,
resourceId, // MongoDB ObjectId for ACL operations
customResourceId: rawResourceId, // Original ID from route params
permission: requiredPermission,
userId,
...(resourceInfo && { resourceInfo }),
};
return next();
}
logger.warn(
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
`(required permission: ${requiredPermission})`,
);
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${resourceType}`,
});
} catch (error) {
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check resource access permissions',
});
}
};
};
module.exports = {
canAccessResource,
};

View File

@@ -1,9 +0,0 @@
const { canAccessResource } = require('./canAccessResource');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
module.exports = {
canAccessResource,
canAccessAgentResource,
canAccessAgentFromBody,
};

View File

@@ -1,7 +1,6 @@
const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendError } = require('~/server/middleware/error');
const { sendMessage, sendError } = require('~/server/utils');
const { saveMessage } = require('~/models');
/**
@@ -37,7 +36,7 @@ const denyRequest = async (req, res, errorMessage) => {
isCreatedByUser: true,
text,
};
sendEvent(res, { message: userMessage, created: true });
sendMessage(res, { message: userMessage, created: true });
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;

View File

@@ -8,7 +8,6 @@ const concurrentLimiter = require('./concurrentLimiter');
const validateEndpoint = require('./validateEndpoint');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const setBalanceConfig = require('./setBalanceConfig');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
@@ -30,7 +29,6 @@ module.exports = {
...validate,
...limiters,
...roles,
...accessResources,
noIndex,
checkBan,
uaParser,

View File

@@ -0,0 +1,78 @@
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Core function to check if a user has one or more required permissions
*
* @param {object} user - The user object
* @param {PermissionTypes} permissionType - The type of permission to check
* @param {Permissions[]} permissions - The list of specific permissions to check
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of properties to check
* @param {object} [checkObject] - The object to check properties against
* @returns {Promise<boolean>} Whether the user has the required permissions
*/
const checkAccess = async (user, permissionType, permissions, bodyProps = {}, checkObject = {}) => {
if (!user) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (role.permissions[permissionType][permission]) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
*
* @param {PermissionTypes} permissionType - The type of permission to check.
* @param {Permissions[]} permissions - The list of specific permissions to check.
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @returns {(req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<void>} Express middleware function.
*/
const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess(
req.user,
permissionType,
permissions,
bodyProps,
req.body,
);
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: Insufficient permissions for User ${req.user.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({ message: `Server error: ${error.message}` });
}
};
};
module.exports = {
checkAccess,
generateCheckAccess,
};

View File

@@ -1,357 +0,0 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { Role } = require('~/db/models');
// Mock the logger from @librechat/data-schemas
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
originalUrl: '/test',
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has any of multiple permissions', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
const req = { body: { id: 'agent123' } };
const result = await checkAccess({
req,
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.UPDATE],
bodyProps: {
[Permissions.UPDATE]: ['id'],
},
checkObject: req.body,
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'limited' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
const createResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true);
const shareResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
getRoleByName,
});
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should check body properties when configured', async () => {
req.body = { agentId: 'agent123', description: 'test' };
const bodyProps = {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
bodyProps,
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should handle database errors gracefully', async () => {
// Mock getRoleByName to throw an error
const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should handle role with no AGENTS permissions', async () => {
await Role.create({
name: 'noaccess',
permissions: {
// Explicitly set AGENTS with all permissions false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
},
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
});
});

View File

@@ -1,5 +1,8 @@
const checkAdmin = require('./admin');
const { checkAccess, generateCheckAccess } = require('./access');
module.exports = {
checkAdmin,
checkAccess,
generateCheckAccess,
};

View File

@@ -1,62 +0,0 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const {
getUserEffectivePermissions,
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
searchPrincipals,
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const router = express.Router();
// Apply common middleware
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
/**
* Generic routes for resource permissions
* Pattern: /api/permissions/{resourceType}/{resourceId}
*/
/**
* GET /api/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles
* Get available roles for a resource type
*/
router.get('/:resourceType/roles', getResourceRoles);
/**
* GET /api/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
*/
router.get('/:resourceType/:resourceId', getResourcePermissions);
/**
* PUT /api/permissions/{resourceType}/{resourceId}
* Bulk update permissions for a specific resource
*/
router.put(
'/:resourceType/:resourceId',
canAccessResource({
resourceType: 'agent',
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
}),
updateResourcePermissions,
);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource
*/
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
module.exports = router;

View File

@@ -1,27 +1,19 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { generateCheckAccess } = require('@librechat/api');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
Permissions,
PermissionTypes,
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
const router = express.Router();
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
// If the user has ADMIN role
// then action edition is possible even if not owner of the assistant
const isAdmin = (req) => {
return req.user.role === SystemRoles.ADMIN;
};
/**
* Retrieves all user's actions
@@ -31,8 +23,9 @@ const checkAgentCreate = generateCheckAccess({
*/
router.get('/', async (req, res) => {
try {
// Get all actions for the user (admin permissions handled by middleware if needed)
const searchParams = { user: req.user.id };
const admin = isAdmin(req);
// If admin, get all actions, otherwise only user's actions
const searchParams = admin ? {} : { user: req.user.id };
res.json(await getActions(searchParams));
} catch (error) {
res.status(500).json({ error: error.message });
@@ -48,111 +41,106 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json
*/
router.post(
'/:agent_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id } = req.params;
router.post('/:agent_id', async (req, res) => {
try {
const { agent_id } = req.params;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
// Permissions already validated by middleware - load agent directly
initialPromises.push(getAgent({ id: agent_id }));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
{ id: agent_id },
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
},
);
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
const admin = isAdmin(req);
// If admin, can edit any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
// TODO: share agents
initialPromises.push(getAgent(agentQuery));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
agentQuery,
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
/**
* Deletes an action for a specific agent.
@@ -161,56 +149,52 @@ router.post(
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete(
'/:agent_id/:action_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id, action_id } = req.params;
router.delete('/:agent_id/:action_id', async (req, res) => {
try {
const { agent_id, action_id } = req.params;
const admin = isAdmin(req);
// Permissions already validated by middleware - load agent directly
const agent = await getAgent({ id: agent_id });
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
const { tools = [], actions = [] } = agent;
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
}
return true;
});
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
{ id: agent_id },
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
await deleteAction({ action_id });
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
// If admin, can delete any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
const agent = await getAgent(agentQuery);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
},
);
const { tools = [], actions = [] } = agent;
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
}
return true;
});
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
agentQuery,
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
// If admin, can delete any action, otherwise only user's actions
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
await deleteAction(actionQuery);
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
module.exports = router;

View File

@@ -1,36 +1,24 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
moderateText,
// validateModel,
generateCheckAccess,
validateConvoAccess,
buildEndpointOption,
canAccessAgentFromBody,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
router.use(moderateText);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
skipCheck: skipAgentCheck,
getRoleByName,
});
const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
router.use(validateConvoAccess);
router.use(buildEndpointOption);
router.use(setHeaders);

View File

@@ -37,6 +37,4 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
module.exports = router;

View File

@@ -1,37 +1,29 @@
const express = require('express');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionBits } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role');
const actions = require('./actions');
const tools = require('./tools');
const router = express.Router();
const avatar = express.Router();
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkGlobalAgentShare = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
const checkGlobalAgentShare = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.USE, Permissions.CREATE],
{
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
getRoleByName,
});
);
router.use(requireJwtAuth);
router.use(checkAgentAccess);
/**
* Agent actions route.
@@ -45,11 +37,6 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', v1.getAgentCategories);
/**
* Creates an agent.
* @route POST /agents
@@ -59,38 +46,13 @@ router.get('/categories', v1.getAgentCategories);
router.post('/', checkAgentCreate, v1.createAgent);
/**
* Retrieves basic agent information (VIEW permission required).
* Returns safe, non-sensitive agent data for viewing purposes.
* Retrieves an agent.
* @route GET /agents/:id
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Basic agent info - application/json
* @returns {Agent} 200 - Success response - application/json
*/
router.get(
'/:id',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.getAgent,
);
router.get('/:id', checkAgentAccess, v1.getAgent);
/**
* Retrieves full agent details including sensitive configuration (EDIT permission required).
* Returns complete agent data for editing/configuration purposes.
* @route GET /agents/:id/expanded
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Full agent details - application/json
*/
router.get(
'/:id/expanded',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
(req, res) => v1.getAgent(req, res, true), // Expanded version
);
/**
* Updates an agent.
* @route PATCH /agents/:id
@@ -98,15 +60,7 @@ router.get(
* @param {AgentUpdateParams} req.body - The agent update parameters.
* @returns {Agent} 200 - Success response - application/json
*/
router.patch(
'/:id',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.updateAgent,
);
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
/**
* Duplicates an agent.
@@ -114,15 +68,7 @@ router.patch(
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - Success response - application/json
*/
router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.duplicateAgent,
);
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
/**
* Deletes an agent.
@@ -130,15 +76,7 @@ router.post(
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json
*/
router.delete(
'/:id',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'id',
}),
v1.deleteAgent,
);
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
/**
* Reverts an agent to a previous version.
@@ -165,14 +103,6 @@ router.get('/', checkAgentAccess, v1.getListAgents);
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
* @returns {Object} 200 - success response - application/json
*/
avatar.post(
'/:agent_id/avatar/',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
v1.uploadAgentAvatar,
);
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
module.exports = { v1: router, avatar };

View File

@@ -1,4 +1,3 @@
const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
@@ -29,7 +28,6 @@ const user = require('./user');
const mcp = require('./mcp');
module.exports = {
mcp,
edit,
auth,
keys,
@@ -57,5 +55,5 @@ module.exports = {
assistants,
categories,
staticRoute,
accessPermissions,
mcp,
};

View File

@@ -1,43 +1,37 @@
const express = require('express');
const { Tokenizer, generateCheckAccess } = require('@librechat/api');
const { Tokenizer } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getAllUserMemories,
toggleUserMemories,
createMemory,
deleteMemory,
setMemory,
deleteMemory,
} = require('~/models');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const router = express.Router();
const checkMemoryRead = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.READ],
getRoleByName,
});
const checkMemoryCreate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkMemoryUpdate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryDelete = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryOptOut = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.OPT_OUT],
getRoleByName,
});
const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.READ,
]);
const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.CREATE,
]);
const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.OPT_OUT,
]);
router.use(requireJwtAuth);

View File

@@ -9,7 +9,6 @@ const {
setBalanceConfig,
checkDomainAllowed,
} = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -36,7 +35,6 @@ const oauthHandler = async (req, res) => {
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res);
} else {
await setAuthTokens(req.user._id, res);

View File

@@ -1,7 +1,5 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider');
const {
getPrompt,
getPrompts,
@@ -16,30 +14,24 @@ const {
// updatePromptLabels,
makePromptProduction,
} = require('~/models/Prompt');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const router = express.Router();
const checkPromptAccess = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkPromptCreate = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]);
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkGlobalPromptShare = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
const checkGlobalPromptShare = generateCheckAccess(
PermissionTypes.PROMPTS,
[Permissions.USE, Permissions.CREATE],
{
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
getRoleByName,
});
);
router.use(requireJwtAuth);
router.use(checkPromptAccess);

View File

@@ -1,24 +1,18 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
updateTagsForConversation,
getConversationTags,
updateConversationTag,
createConversationTag,
deleteConversationTag,
getConversationTags,
updateTagsForConversation,
} = require('~/models/ConversationTag');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const router = express.Router();
const checkBookmarkAccess = generateCheckAccess({
permissionType: PermissionTypes.BOOKMARKS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);

View File

@@ -1,7 +1,5 @@
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View File

@@ -17,7 +17,6 @@ const {
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
@@ -27,6 +26,7 @@ const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService');
const { isEnabled } = require('~/server/utils');
const { initializeRoles } = require('~/models');
const { setCachedTools } = require('./Config');
const paths = require('~/config/paths');
@@ -37,8 +37,6 @@ const paths = require('~/config/paths');
*/
const AppService = async (app) => {
await initializeRoles();
await seedDefaultRoles();
await ensureDefaultCategories();
/** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults();

View File

@@ -28,8 +28,6 @@ jest.mock('./Files/Firebase/initialize', () => ({
}));
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View File

@@ -1,7 +1,4 @@
const { klona } = require('klona');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
StepTypes,
RunStatus,
@@ -14,10 +11,11 @@ const {
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { RunManager, waitForRun } = require('~/server/services/Runs');
const { processMessages } = require('~/server/services/Threads');
const { createOnProgress } = require('~/server/utils');
const { TextStream } = require('~/app/clients');
const { logger } = require('~/config');
/**
* Sorts, processes, and flattens messages to a single string.
@@ -66,7 +64,7 @@ async function createOnTextProgress({
};
logger.debug('Content data:', contentData);
sendEvent(openai.res, contentData);
sendMessage(openai.res, contentData);
};
}

View File

@@ -1,5 +1,4 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@@ -500,18 +499,6 @@ const resendVerificationEmail = async (req) => {
};
}
};
/**
* Generate a short-lived JWT token
* @param {String} userId - The ID of the user
* @param {String} [expireIn='5m'] - The expiration time for the token (default is 5 minutes)
* @returns {String} - The generated JWT token
*/
const generateShortLivedToken = (userId, expireIn = '5m') => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: expireIn,
algorithm: 'HS256',
});
};
module.exports = {
logoutUser,
@@ -519,8 +506,7 @@ module.exports = {
registerUser,
setAuthTokens,
resetPassword,
setOpenIDAuthTokens,
requestPasswordReset,
resendVerificationEmail,
generateShortLivedToken,
setOpenIDAuthTokens,
};

View File

@@ -1,6 +1,5 @@
const { isUserProvided } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const { generateConfig } = require('~/server/utils/handleText');
const { isUserProvided, generateConfig } = require('~/server/utils');
const {
OPENAI_API_KEY: openAIApiKey,

View File

@@ -1,18 +1,18 @@
const path = require('path');
const axios = require('axios');
const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
CacheKeys,
configSchema,
paramSettings,
EImageOutputType,
agentParamSettings,
validateSettingDefinitions,
agentParamSettings,
paramSettings,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
const axios = require('axios');
const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');

View File

@@ -1,9 +1,6 @@
jest.mock('axios');
jest.mock('~/cache/getLogStores');
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
loadYaml: jest.fn(),
}));
jest.mock('~/utils/loadYaml');
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
@@ -33,22 +30,11 @@ jest.mock('librechat-data-provider', () => {
};
});
jest.mock('@librechat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
describe('loadCustomConfig', () => {
const mockSet = jest.fn();

View File

@@ -1,93 +0,0 @@
const initializeClient = require('./initialize');
jest.mock('@librechat/api', () => ({
resolveHeaders: jest.fn(),
getOpenAIConfig: jest.fn(),
createHandleLLMNewToken: jest.fn(),
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: { TOKEN_CONFIG: 'token_config' },
ErrorTypes: { NO_USER_KEY: 'NO_USER_KEY', NO_BASE_URL: 'NO_BASE_URL' },
envVarRegex: /\$\{([^}]+)\}/,
FetchTokenConfig: {},
extractEnvVariable: jest.fn((value) => value),
}));
jest.mock('@librechat/agents', () => ({
Providers: { OLLAMA: 'ollama' },
}));
jest.mock('~/server/services/UserService', () => ({
getUserKeyValues: jest.fn(),
checkUserKeyExpiry: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getCustomEndpointConfig: jest.fn().mockResolvedValue({
apiKey: 'test-key',
baseURL: 'https://test.com',
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
models: { default: ['test-model'] },
}),
}));
jest.mock('~/server/services/ModelService', () => ({
fetchModels: jest.fn(),
}));
jest.mock('~/app/clients/OpenAIClient', () => {
return jest.fn().mockImplementation(() => ({
options: {},
}));
});
jest.mock('~/server/utils', () => ({
isUserProvided: jest.fn().mockReturnValue(false),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({
get: jest.fn(),
}),
);
describe('custom/initializeClient', () => {
const mockRequest = {
body: { endpoint: 'test-endpoint' },
user: { id: 'user-123', email: 'test@example.com' },
app: { locals: {} },
};
const mockResponse = {};
beforeEach(() => {
jest.clearAllMocks();
});
it('calls resolveHeaders with headers and user', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith(
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
{ id: 'user-123', email: 'test@example.com' },
);
});
it('throws if endpoint config is missing', async () => {
const { getCustomEndpointConfig } = require('~/server/services/Config');
getCustomEndpointConfig.mockResolvedValueOnce(null);
await expect(
initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }),
).rejects.toThrow('Config not found for the test-endpoint custom endpoint.');
});
it('throws if user is missing', async () => {
await expect(
initializeClient({
req: { ...mockRequest, user: undefined },
res: mockResponse,
optionsOnly: true,
}),
).rejects.toThrow("Cannot read properties of undefined (reading 'id')");
});
});

View File

@@ -1,6 +1,7 @@
const { getGoogleConfig, isEnabled } = require('@librechat/api');
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getLLMConfig } = require('~/server/services/Endpoints/google/llm');
const { isEnabled } = require('~/server/utils');
const { GoogleClient } = require('~/app');
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
@@ -64,7 +65,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (overrideModel) {
clientOptions.modelOptions.model = overrideModel;
}
return getGoogleConfig(credentials, clientOptions);
return getLLMConfig(credentials, clientOptions);
}
const client = new GoogleClient(credentials, clientOptions);

View File

@@ -1,15 +1,13 @@
import { Providers } from '@librechat/agents';
import { googleSettings, AuthKeys } from 'librechat-data-provider';
import type { GoogleClientOptions, VertexAIClientOptions } from '@librechat/agents';
import type * as t from '~/types';
import { isEnabled } from '~/utils';
const { Providers } = require('@librechat/agents');
const { AuthKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
function getThresholdMapping(model: string) {
function getThresholdMapping(model) {
const gemini1Pattern = /gemini-(1\.0|1\.5|pro$|1\.0-pro|1\.5-pro|1\.5-flash-001)/;
const restrictedPattern = /(gemini-(1\.5-flash-8b|2\.0|exp)|learnlm)/;
if (gemini1Pattern.test(model)) {
return (value: string) => {
return (value) => {
if (value === 'OFF') {
return 'BLOCK_NONE';
}
@@ -18,7 +16,7 @@ function getThresholdMapping(model: string) {
}
if (restrictedPattern.test(model)) {
return (value: string) => {
return (value) => {
if (value === 'OFF' || value === 'HARM_BLOCK_THRESHOLD_UNSPECIFIED') {
return 'BLOCK_NONE';
}
@@ -26,16 +24,19 @@ function getThresholdMapping(model: string) {
};
}
return (value: string) => value;
return (value) => value;
}
export function getSafetySettings(
model?: string,
): Array<{ category: string; threshold: string }> | undefined {
/**
*
* @param {string} model
* @returns {Array<{category: string, threshold: string}> | undefined}
*/
function getSafetySettings(model) {
if (isEnabled(process.env.GOOGLE_EXCLUDE_SAFETY_SETTINGS)) {
return undefined;
}
const mapThreshold = getThresholdMapping(model ?? '');
const mapThreshold = getThresholdMapping(model);
return [
{
@@ -73,27 +74,24 @@ export function getSafetySettings(
* Replicates core logic from GoogleClient's constructor and setOptions, plus client determination.
* Returns an object with the provider label and the final options that would be passed to createLLM.
*
* @param credentials - Either a JSON string or an object containing Google keys
* @param options - The same shape as the "GoogleClient" constructor options
* @param {string | object} credentials - Either a JSON string or an object containing Google keys
* @param {object} [options={}] - The same shape as the "GoogleClient" constructor options
*/
export function getGoogleConfig(
credentials: string | t.GoogleCredentials | undefined,
options: t.GoogleConfigOptions = {},
) {
let creds: t.GoogleCredentials = {};
function getLLMConfig(credentials, options = {}) {
// 1. Parse credentials
let creds = {};
if (typeof credentials === 'string') {
try {
creds = JSON.parse(credentials);
} catch (err: unknown) {
throw new Error(
`Error parsing string credentials: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
} catch (err) {
throw new Error(`Error parsing string credentials: ${err.message}`);
}
} else if (credentials && typeof credentials === 'object') {
creds = credentials;
}
// Extract from credentials
const serviceKeyRaw = creds[AuthKeys.GOOGLE_SERVICE_KEY] ?? {};
const serviceKey =
typeof serviceKeyRaw === 'string' ? JSON.parse(serviceKeyRaw) : (serviceKeyRaw ?? {});
@@ -104,15 +102,9 @@ export function getGoogleConfig(
const reverseProxyUrl = options.reverseProxyUrl;
const authHeader = options.authHeader;
const {
thinking = googleSettings.thinking.default,
thinkingBudget = googleSettings.thinkingBudget.default,
...modelOptions
} = options.modelOptions || {};
const llmConfig: GoogleClientOptions | VertexAIClientOptions = {
...(modelOptions || {}),
model: modelOptions?.model ?? '',
/** @type {GoogleClientOptions | VertexAIClientOptions} */
let llmConfig = {
...(options.modelOptions || {}),
maxRetries: 2,
};
@@ -129,30 +121,16 @@ export function getGoogleConfig(
// If we have a GCP project => Vertex AI
if (project_id && provider === Providers.VERTEXAI) {
(llmConfig as VertexAIClientOptions).authOptions = {
/** @type {VertexAIClientOptions['authOptions']} */
llmConfig.authOptions = {
credentials: { ...serviceKey },
projectId: project_id,
};
(llmConfig as VertexAIClientOptions).location = process.env.GOOGLE_LOC || 'us-central1';
llmConfig.location = process.env.GOOGLE_LOC || 'us-central1';
} else if (apiKey && provider === Providers.GOOGLE) {
llmConfig.apiKey = apiKey;
}
const shouldEnableThinking =
thinking && thinkingBudget != null && (thinkingBudget > 0 || thinkingBudget === -1);
if (shouldEnableThinking && provider === Providers.GOOGLE) {
(llmConfig as GoogleClientOptions).thinkingConfig = {
thinkingBudget: thinking ? thinkingBudget : googleSettings.thinkingBudget.default,
includeThoughts: Boolean(thinking),
};
} else if (shouldEnableThinking && provider === Providers.VERTEXAI) {
(llmConfig as VertexAIClientOptions).thinkingBudget = thinking
? thinkingBudget
: googleSettings.thinkingBudget.default;
(llmConfig as VertexAIClientOptions).includeThoughts = Boolean(thinking);
}
/*
let legacyOptions = {};
// Filter out any "examples" that are empty
@@ -174,11 +152,11 @@ export function getGoogleConfig(
*/
if (reverseProxyUrl) {
(llmConfig as GoogleClientOptions).baseUrl = reverseProxyUrl;
llmConfig.baseUrl = reverseProxyUrl;
}
if (authHeader) {
(llmConfig as GoogleClientOptions).customHeaders = {
llmConfig.customHeaders = {
Authorization: `Bearer ${apiKey}`,
};
}
@@ -191,3 +169,8 @@ export function getGoogleConfig(
llmConfig,
};
}
module.exports = {
getLLMConfig,
getSafetySettings,
};

View File

@@ -1,11 +1,10 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths');
const { logger } = require('~/config');
/**
* Saves a file to a specified output path with a new filename.
@@ -207,7 +206,7 @@ const deleteLocalFile = async (req, file) => {
const cleanFilepath = file.filepath.split('?')[0];
if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = req.headers.authorization.split(' ')[1];
axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,

View File

@@ -4,7 +4,6 @@ const FormData = require('form-data');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
/**
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and
@@ -24,8 +23,7 @@ const deleteVectors = async (req, file) => {
return;
}
try {
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = req.headers.authorization.split(' ')[1];
return await axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
@@ -72,7 +70,7 @@ async function uploadVectors({ req, file, file_id, entity_id }) {
}
try {
const jwtToken = generateShortLivedToken(req.user.id);
const jwtToken = req.headers.authorization.split(' ')[1];
const formData = new FormData();
formData.append('file_id', file_id);
formData.append('file', fs.createReadStream(file.path));

View File

@@ -55,9 +55,7 @@ const processFiles = async (files, fileIds) => {
}
if (!fileIds) {
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
return await Promise.all(promises);
}
for (let file_id of fileIds) {
@@ -69,9 +67,7 @@ const processFiles = async (files, fileIds) => {
}
// TODO: calculate token cost when image is first uploaded
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
return await Promise.all(promises);
};
/**

View File

@@ -1,208 +0,0 @@
// Mock the updateFileUsage function before importing the actual processFiles
jest.mock('~/models/File', () => ({
updateFileUsage: jest.fn(),
}));
// Mock winston and logger configuration to avoid dependency issues
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
// Mock all other dependencies that might cause issues
jest.mock('librechat-data-provider', () => ({
isUUID: { parse: jest.fn() },
megabyte: 1024 * 1024,
FileContext: { message_attachment: 'message_attachment' },
FileSources: { local: 'local' },
EModelEndpoint: { assistants: 'assistants' },
EToolResources: { file_search: 'file_search' },
mergeFileConfig: jest.fn(),
removeNullishValues: jest.fn((obj) => obj),
isAssistantsEndpoint: jest.fn(),
}));
jest.mock('~/server/services/Files/images', () => ({
convertImage: jest.fn(),
resizeAndConvert: jest.fn(),
resizeImageBuffer: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/v2', () => ({
addResourceFileId: jest.fn(),
deleteResourceFileId: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn(),
removeAgentResourceFiles: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
checkCapability: jest.fn(),
}));
jest.mock('~/server/utils/queue', () => ({
LB_QueueAsyncCall: jest.fn(),
}));
jest.mock('./strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn(),
}));
// Import the actual processFiles function after all mocks are set up
const { processFiles } = require('./process');
const { updateFileUsage } = require('~/models/File');
describe('processFiles', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('null filtering functionality', () => {
it('should filter out null results from updateFileUsage when files do not exist', async () => {
const mockFiles = [
{ file_id: 'existing-file-1' },
{ file_id: 'non-existent-file' },
{ file_id: 'existing-file-2' },
];
// Mock updateFileUsage to return null for non-existent files
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null); // Simulate file not found in the database
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(3);
expect(result).toEqual([
{ file_id: 'existing-file-1', usage: 1 },
{ file_id: 'existing-file-2', usage: 1 },
]);
// Critical test - ensure no null values in result
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2); // Only valid files should be returned
});
it('should return empty array when all updateFileUsage calls return null', async () => {
const mockFiles = [{ file_id: 'non-existent-1' }, { file_id: 'non-existent-2' }];
// All updateFileUsage calls return null
updateFileUsage.mockResolvedValue(null);
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([]);
expect(result).not.toContain(null);
expect(result.length).toBe(0);
});
it('should work correctly when all files exist', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result.length).toBe(2);
});
it('should handle fileIds parameter and filter nulls correctly', async () => {
const mockFiles = [{ file_id: 'file-1' }];
const mockFileIds = ['file-2', 'non-existent-file'];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null);
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles, mockFileIds);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2);
});
it('should handle duplicate file_ids correctly', async () => {
const mockFiles = [
{ file_id: 'duplicate-file' },
{ file_id: 'duplicate-file' }, // Duplicate should be ignored
{ file_id: 'unique-file' },
];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
// Should only call updateFileUsage twice (duplicate ignored)
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{ file_id: 'duplicate-file', usage: 1 },
{ file_id: 'unique-file', usage: 1 },
]);
expect(result.length).toBe(2);
});
});
describe('edge cases', () => {
it('should handle empty files array', async () => {
const result = await processFiles([]);
expect(result).toEqual([]);
expect(updateFileUsage).not.toHaveBeenCalled();
});
it('should handle mixed null and undefined returns from updateFileUsage', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }, { file_id: 'file-3' }];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'file-1') return Promise.resolve(null);
if (file_id === 'file-2') return Promise.resolve(undefined);
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([{ file_id: 'file-3', usage: 1 }]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(1);
});
});
});

View File

@@ -1,525 +0,0 @@
const client = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
/**
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider'
*/
/**
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
* @param {Object} user - User object from request
* @param {string} user.provider - Authentication provider
* @param {string} user.openidId - OpenID subject identifier
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
*/
const entraIdPrincipalFeatureEnabled = (user) => {
return (
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
user?.provider === 'openid' &&
user?.openidId
);
};
/**
* Creates a Microsoft Graph client with on-behalf-of token exchange
* @param {string} accessToken - OpenID Connect access token from user
* @param {string} sub - Subject identifier from token claims
* @returns {Promise<Client>} Authenticated Graph API client
*/
const createGraphClient = async (accessToken, sub) => {
try {
// Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js
const openidConfig = getOpenIdConfig();
const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub);
const graphClient = Client.init({
authProvider: (done) => {
done(null, exchangedToken);
},
});
return graphClient;
} catch (error) {
logger.error('[createGraphClient] Error creating Graph client:', error);
throw error;
}
};
/**
* Exchange OpenID token for Graph API access using on-behalf-of flow
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
* @param {Configuration} config - OpenID configuration
* @param {string} accessToken - Original access token
* @param {string} sub - Subject identifier
* @returns {Promise<string>} Graph API access token
*/
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
try {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const cacheKey = `${sub}:graph`;
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
return cachedToken.access_token;
}
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
const scopeString = graphScopes
.split(',')
.map((scope) => `https://graph.microsoft.com/${scope}`)
.join(' ');
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopeString,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
await tokensCache.set(
cacheKey,
{
access_token: grantResponse.access_token,
},
grantResponse.expires_in * 1000,
);
return grantResponse.access_token;
} catch (error) {
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
throw error;
}
};
/**
* Search for principals (people and groups) using Microsoft Graph API
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} query - Search query string
* @param {string} type - Type filter ('users', 'groups', or 'all')
* @param {number} limit - Maximum number of results
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
*/
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
const graphClient = await createGraphClient(accessToken, sub);
let allResults = [];
if (type === 'users' || type === 'all') {
const contactResults = await searchContacts(graphClient, query, limit);
allResults.push(...contactResults);
}
if (allResults.length >= limit) {
return allResults.slice(0, limit);
}
if (type === 'users') {
const userResults = await searchUsers(graphClient, query, limit);
allResults.push(...userResults);
} else if (type === 'groups') {
const groupResults = await searchGroups(graphClient, query, limit);
allResults.push(...groupResults);
} else if (type === 'all') {
const [userResults, groupResults] = await Promise.all([
searchUsers(graphClient, query, limit),
searchGroups(graphClient, query, limit),
]);
allResults.push(...userResults, ...groupResults);
}
const seenIds = new Set();
const uniqueResults = allResults.filter((result) => {
if (seenIds.has(result.idOnTheSource)) {
return false;
}
seenIds.add(result.idOnTheSource);
return true;
});
return uniqueResults.slice(0, limit);
} catch (error) {
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
return [];
}
};
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
}
};
/**
* Get current user's owned Entra ID groups from Microsoft Graph
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
}
};
/**
* Get group members from Microsoft Graph API
* Recursively fetches all members using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
*/
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allMembers;
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];
}
};
/**
* Get group owners from Microsoft Graph API
* Recursively fetches all owners using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
*/
const getGroupOwners = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allOwners = [];
let nextLink = `/groups/${groupId}/owners`;
while (nextLink) {
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const owners = ownersResponse.value || [];
allOwners.push(...owners.map((member) => member.id));
nextLink = ownersResponse['@odata.nextLink']
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allOwners;
} catch (error) {
logger.error('[getGroupOwners] Error fetching group owners:', error);
return [];
}
};
/**
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
* Returns mapped TPrincipalSearchResult objects for users only
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
*/
const searchContacts = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
if (
process.env.OPENID_GRAPH_SCOPES &&
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
) {
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
return [];
}
// Reason: Search only for OrganizationUser (person) type, not groups
const filter = "personType/subclass eq 'OrganizationUser'";
let apiCall = graphClient
.api('/me/people')
.search(`"${query}"`)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
)
.header('ConsistencyLevel', 'eventual')
.filter(filter)
.top(limit);
const contactsResponse = await apiCall.get();
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchContacts] Error searching contacts:', error);
return [];
}
};
/**
* Search for users using Microsoft Graph /users endpoint
* Returns mapped TPrincipalSearchResult objects
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
*/
const searchUsers = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search users by display name, email, and user principal name
const usersResponse = await graphClient
.api('/users')
.search(
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
)
.select(
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
)
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchUsers] Error searching users:', error);
return [];
}
};
/**
* Search for groups using Microsoft Graph /groups endpoint
* Returns mapped TPrincipalSearchResult objects, includes all group types
* @param {Client} graphClient - Authenticated Microsoft Graph client
* @param {string} query - Search query string
* @param {number} limit - Maximum number of results (default: 10)
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
*/
const searchGroups = async (graphClient, query, limit = 10) => {
try {
if (!query || query.trim().length < 2) {
return [];
}
// Reason: Search all groups by display name and email without filtering group types
const groupsResponse = await graphClient
.api('/groups')
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
.header('ConsistencyLevel', 'eventual')
.top(limit)
.get();
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
} catch (error) {
logger.error('[searchGroups] Error searching groups:', error);
return [];
}
};
/**
* Test Graph API connectivity and permissions
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Object>} Test results with available permissions
*/
const testGraphApiAccess = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const results = {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [],
};
// Test User.Read permission
try {
await graphClient.api('/me').select('id,displayName').get();
results.userAccess = true;
} catch (error) {
results.errors.push(`User.Read: ${error.message}`);
}
// Test People.Read permission with OrganizationUser filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'OrganizationUser'")
.top(1)
.get();
results.peopleAccess = true;
} catch (error) {
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
}
// Test People.Read permission with UnifiedGroup filter
try {
await graphClient
.api('/me/people')
.filter("personType/subclass eq 'UnifiedGroup'")
.top(1)
.get();
results.groupsAccess = true;
} catch (error) {
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
}
// Test /users endpoint access (requires User.Read.All or similar)
try {
await graphClient
.api('/users')
.search('"displayName:test"')
.select('id,displayName,userPrincipalName')
.top(1)
.get();
results.usersEndpointAccess = true;
} catch (error) {
results.errors.push(`Users endpoint: ${error.message}`);
}
// Test /groups endpoint access (requires Group.Read.All or similar)
try {
await graphClient
.api('/groups')
.search('"displayName:test"')
.select('id,displayName,mail')
.top(1)
.get();
results.groupsEndpointAccess = true;
} catch (error) {
results.errors.push(`Groups endpoint: ${error.message}`);
}
return results;
} catch (error) {
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
return {
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [error.message],
};
}
};
/**
* Map Graph API user object to TPrincipalSearchResult format
* @param {TGraphUser} user - Raw user object from Graph API
* @returns {TPrincipalSearchResult} Mapped user result
*/
const mapUserToTPrincipalSearchResult = (user) => {
return {
id: null,
type: 'user',
name: user.displayName,
email: user.mail || user.userPrincipalName,
username: user.userPrincipalName,
source: 'entra',
idOnTheSource: user.id,
};
};
/**
* Map Graph API group object to TPrincipalSearchResult format
* @param {TGraphGroup} group - Raw group object from Graph API
* @returns {TPrincipalSearchResult} Mapped group result
*/
const mapGroupToTPrincipalSearchResult = (group) => {
return {
id: null,
type: 'group',
name: group.displayName,
email: group.mail || group.userPrincipalName,
description: group.description,
source: 'entra',
idOnTheSource: group.id,
};
};
/**
* Map Graph API /me/people contact object to TPrincipalSearchResult format
* Handles both user and group contacts from the people endpoint
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
* @returns {TPrincipalSearchResult} Mapped contact result
*/
const mapContactToTPrincipalSearchResult = (contact) => {
const isGroup = contact.personType?.class === 'Group';
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
return {
id: null,
type: isGroup ? 'group' : 'user',
name: contact.displayName,
email: primaryEmail,
username: !isGroup ? contact.userPrincipalName : undefined,
source: 'entra',
idOnTheSource: contact.id,
};
};
module.exports = {
getGroupMembers,
getGroupOwners,
createGraphClient,
getUserEntraGroups,
getUserOwnedEntraGroups,
testGraphApiAccess,
searchEntraIdPrincipals,
exchangeTokenForGraphAccess,
entraIdPrincipalFeatureEnabled,
};

View File

@@ -1,720 +0,0 @@
jest.mock('@microsoft/microsoft-graph-client');
jest.mock('~/strategies/openidStrategy');
jest.mock('~/cache/getLogStores');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
debug: jest.fn(),
},
createAxiosInstance: jest.fn(() => ({
create: jest.fn(),
defaults: {},
})),
}));
jest.mock('~/utils', () => ({
logAxiosError: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
const mongoose = require('mongoose');
const client = require('openid-client');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { Client } = require('@microsoft/microsoft-graph-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const GraphApiService = require('./GraphApiService');
describe('GraphApiService', () => {
let mongoServer;
let mockGraphClient;
let mockTokensCache;
let mockOpenIdConfig;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(() => {
// Clean up environment variables
delete process.env.OPENID_GRAPH_SCOPES;
});
beforeEach(async () => {
jest.clearAllMocks();
await mongoose.connection.dropDatabase();
// Set up environment variable for People.Read scope
process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All';
// Mock Graph client
mockGraphClient = {
api: jest.fn().mockReturnThis(),
search: jest.fn().mockReturnThis(),
filter: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
// Mock tokens cache
mockTokensCache = {
get: jest.fn(),
set: jest.fn(),
};
getLogStores.mockReturnValue(mockTokensCache);
// Mock OpenID config
mockOpenIdConfig = {
client_id: 'test-client-id',
issuer: 'https://test-issuer.com',
};
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
// Mock openid-client (using the existing jest mock configuration)
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
});
describe('Dependency Contract Tests', () => {
it('should fail if getOpenIdConfig interface changes', () => {
// Reason: Ensure getOpenIdConfig returns expected structure
const config = getOpenIdConfig();
expect(config).toBeDefined();
expect(typeof config).toBe('object');
// Add specific property checks that GraphApiService depends on
expect(config).toHaveProperty('client_id');
expect(config).toHaveProperty('issuer');
// Ensure the function is callable
expect(typeof getOpenIdConfig).toBe('function');
});
it('should fail if openid-client.genericGrantRequest interface changes', () => {
// Reason: Ensure client.genericGrantRequest maintains expected signature
if (client.genericGrantRequest) {
expect(typeof client.genericGrantRequest).toBe('function');
// Test that it accepts the expected parameters
const mockCall = client.genericGrantRequest(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'test-scope',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
expect(mockCall).toBeDefined();
}
});
it('should fail if Microsoft Graph Client interface changes', () => {
// Reason: Ensure Graph Client maintains expected fluent API
expect(typeof Client.init).toBe('function');
const client = Client.init({ authProvider: jest.fn() });
expect(client).toHaveProperty('api');
expect(typeof client.api).toBe('function');
});
});
describe('createGraphClient', () => {
it('should create graph client with exchanged token', async () => {
const accessToken = 'test-access-token';
const sub = 'test-user-id';
const result = await GraphApiService.createGraphClient(accessToken, sub);
expect(getOpenIdConfig).toHaveBeenCalled();
expect(Client.init).toHaveBeenCalledWith({
authProvider: expect.any(Function),
});
expect(result).toBe(mockGraphClient);
});
it('should handle token exchange errors gracefully', async () => {
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow(
'Token exchange failed',
);
});
});
describe('exchangeTokenForGraphAccess', () => {
it('should return cached token if available', async () => {
const cachedToken = { access_token: 'cached-token' };
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
expect(result).toBe('cached-token');
expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph');
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).not.toHaveBeenCalled();
}
});
it('should exchange token and cache result', async () => {
mockTokensCache.get.mockResolvedValue(null);
const result = await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
expect(mockTokensCache.set).toHaveBeenCalledWith(
'test-user:graph',
{ access_token: 'mocked-graph-token' },
3600000,
);
expect(result).toBe('mocked-graph-token');
});
it('should use custom scopes from environment', async () => {
const originalEnv = process.env.OPENID_GRAPH_SCOPES;
process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write';
mockTokensCache.get.mockResolvedValue(null);
await GraphApiService.exchangeTokenForGraphAccess(
mockOpenIdConfig,
'test-token',
'test-user',
);
if (client.genericGrantRequest) {
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope:
'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write',
assertion: 'test-token',
requested_token_use: 'on_behalf_of',
},
);
}
process.env.OPENID_GRAPH_SCOPES = originalEnv;
});
});
describe('searchEntraIdPrincipals', () => {
// Mock data used by multiple tests
const mockContactsResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
{
id: 'contact-group-1',
displayName: 'Marketing Team',
mail: 'marketing@company.com',
personType: { class: 'Group', subclass: 'UnifiedGroup' },
scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }],
},
],
};
const mockUsersResponse = {
value: [
{
id: 'dir-user-1',
displayName: 'Jane Smith',
userPrincipalName: 'jane@company.com',
mail: 'jane@company.com',
},
],
};
const mockGroupsResponse = {
value: [
{
id: 'dir-group-1',
displayName: 'Development Team',
mail: 'dev@company.com',
},
],
};
beforeEach(() => {
// Reset mock call history for each test
jest.clearAllMocks();
// Re-apply the Client.init mock after clearAllMocks
Client.init.mockReturnValue(mockGraphClient);
// Re-apply openid-client mock
if (client.genericGrantRequest) {
client.genericGrantRequest.mockResolvedValue({
access_token: 'mocked-graph-token',
expires_in: 3600,
});
}
// Re-apply cache mock
mockTokensCache.get.mockResolvedValue(null); // Force token exchange
mockTokensCache.set.mockResolvedValue();
getLogStores.mockReturnValue(mockTokensCache);
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
});
it('should return empty results for short queries', async () => {
const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10);
expect(result).toEqual([]);
expect(mockGraphClient.api).not.toHaveBeenCalled();
});
it('should search contacts first and additional users for users type', async () => {
// Mock responses for this specific test
const contactsFilteredResponse = {
value: [
{
id: 'contact-user-1',
displayName: 'John Doe',
userPrincipalName: 'john@company.com',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(contactsFilteredResponse) // contacts call
.mockResolvedValueOnce(mockUsersResponse); // users call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"john"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call users endpoint for additional results
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"',
);
// Should return TPrincipalSearchResult array
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2); // 1 from contacts + 1 from users
expect(result[0]).toMatchObject({
id: null,
type: 'user',
name: 'John Doe',
email: 'john@company.com',
source: 'entra',
idOnTheSource: 'contact-user-1',
});
});
it('should search groups endpoint only for groups type', async () => {
// Mock responses for this specific test - only groups endpoint called
mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'team',
'groups',
10,
);
// Should NOT call contacts for groups type
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people');
// Should call groups endpoint only
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.search).toHaveBeenCalledWith(
'"displayName:team" OR "mail:team" OR "mailNickname:team"',
);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1); // 1 from groups only
});
it('should search all endpoints for all type', async () => {
// Mock responses for this specific test
mockGraphClient.get
.mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group)
.mockResolvedValueOnce(mockUsersResponse) // users call
.mockResolvedValueOnce(mockGroupsResponse); // groups call
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first with user filter
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
// Should call both users and groups endpoints
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups
});
it('should early exit if contacts reach limit', async () => {
// Mock contacts to return exactly the limit
const limitedContactsResponse = {
value: Array(10).fill({
id: 'contact-1',
displayName: 'Contact User',
mail: 'contact@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
}),
};
mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
// Should call contacts first
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
// Should not call users endpoint since limit was reached
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users');
expect(result).toHaveLength(10);
});
it('should deduplicate results based on idOnTheSource', async () => {
// Mock responses with duplicate IDs
const duplicateContactsResponse = {
value: [
{
id: 'duplicate-id',
displayName: 'John Doe',
mail: 'john@company.com',
personType: { class: 'Person', subclass: 'OrganizationUser' },
},
],
};
const duplicateUsersResponse = {
value: [
{
id: 'duplicate-id', // Same ID as contact
displayName: 'John Doe',
mail: 'john@company.com',
},
],
};
mockGraphClient.get
.mockResolvedValueOnce(duplicateContactsResponse)
.mockResolvedValueOnce(duplicateUsersResponse);
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'john',
'users',
10,
);
// Should only return one result despite duplicate IDs
expect(result).toHaveLength(1);
expect(result[0].idOnTheSource).toBe('duplicate-id');
});
it('should handle Graph API errors gracefully', async () => {
mockGraphClient.get.mockRejectedValue(new Error('Graph API error'));
const result = await GraphApiService.searchEntraIdPrincipals(
'token',
'user',
'test',
'all',
10,
);
expect(result).toEqual([]);
});
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle empty response', async () => {
const mockGroupsResponse = {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should test all permissions and return success results', async () => {
// Mock successful responses for all tests
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser test
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test
.mockResolvedValueOnce({ value: [] }) // /users endpoint test
.mockResolvedValueOnce({ value: [] }); // /groups endpoint test
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: true,
groupsEndpointAccess: true,
errors: [],
});
// Verify all endpoints were tested
expect(mockGraphClient.api).toHaveBeenCalledWith('/me');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.filter).toHaveBeenCalledWith(
"personType/subclass eq 'OrganizationUser'",
);
expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'");
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
});
it('should handle partial failures and record errors', async () => {
// Mock mixed success/failure responses
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail
.mockResolvedValueOnce({ value: [] }); // /groups success
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: false,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: true,
errors: [
'People.Read (OrganizationUser): People access denied',
'Users endpoint: Users endpoint access denied',
],
});
});
it('should handle complete Graph client creation failure', async () => {
// Mock token exchange failure to test error handling
if (client.genericGrantRequest) {
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
}
const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: ['Token exchange failed'],
});
});
it('should record all permission errors', async () => {
// Mock all requests to fail
mockGraphClient.get
.mockRejectedValueOnce(new Error('User.Read denied'))
.mockRejectedValueOnce(new Error('People.Read OrganizationUser denied'))
.mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied'))
.mockRejectedValueOnce(new Error('Users directory access denied'))
.mockRejectedValueOnce(new Error('Groups directory access denied'));
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: false,
peopleAccess: false,
groupsAccess: false,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'User.Read: User.Read denied',
'People.Read (OrganizationUser): People.Read OrganizationUser denied',
'People.Read (UnifiedGroup): People.Read UnifiedGroup denied',
'Users endpoint: Users directory access denied',
'Groups endpoint: Groups directory access denied',
],
});
});
it('should test new endpoints with correct search patterns', async () => {
// Mock successful responses for endpoint testing
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup
.mockResolvedValueOnce({ value: [] }) // /users
.mockResolvedValueOnce({ value: [] }); // /groups
await GraphApiService.testGraphApiAccess('token', 'user');
// Verify /users endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName');
// Verify /groups endpoint test
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail');
});
it('should handle endpoint-specific permission failures', async () => {
// Mock specific endpoint failures
mockGraphClient.get
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser success
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
.mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed)
.mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed)
const result = await GraphApiService.testGraphApiAccess('token', 'user');
expect(result).toEqual({
userAccess: true,
peopleAccess: true,
groupsAccess: true,
usersEndpointAccess: false,
groupsEndpointAccess: false,
errors: [
'Users endpoint: Insufficient privileges',
'Groups endpoint: Access denied to groups',
],
});
});
});
});

View File

@@ -1,721 +0,0 @@
const mongoose = require('mongoose');
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils');
const {
entraIdPrincipalFeatureEnabled,
getUserEntraGroups,
getUserOwnedEntraGroups,
getGroupMembers,
getGroupOwners,
} = require('~/server/services/GraphApiService');
const {
findGroupByExternalId,
findRoleByIdentifier,
getUserPrincipals,
createGroup,
createUser,
updateUser,
findUser,
grantPermission: grantPermissionACL,
findAccessibleResources: findAccessibleResourcesACL,
hasPermission,
getEffectivePermissions: getEffectivePermissionsACL,
findEntriesByPrincipalsAndResource,
} = require('~/models');
const { AclEntry, AccessRole, Group } = require('~/db/models');
/** @type {boolean|null} */
let transactionSupportCache = null;
/**
* @import { TPrincipal } from 'librechat-data-provider'
*/
/**
* Grant a permission to a principal for a resource using a role
* @param {Object} params - Parameters for granting role-based permission
* @param {string} params.principalType - 'user', 'group', or 'public'
* @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public')
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor')
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} The created or updated ACL entry
*/
const grantPermission = async ({
principalType,
principalId,
resourceType,
resourceId,
accessRoleId,
grantedBy,
session,
}) => {
try {
if (!['user', 'group', 'public'].includes(principalType)) {
throw new Error(`Invalid principal type: ${principalType}`);
}
if (principalType !== 'public' && !principalId) {
throw new Error('Principal ID is required for user and group principals');
}
if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) {
throw new Error(`Invalid principal ID: ${principalId}`);
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
// Get the role to determine permission bits
const role = await findRoleByIdentifier(accessRoleId);
if (!role) {
throw new Error(`Role ${accessRoleId} not found`);
}
// Ensure the role is for the correct resource type
if (role.resourceType !== resourceType) {
throw new Error(
`Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`,
);
}
return await grantPermissionACL(
principalType,
principalId,
resourceType,
resourceId,
role.permBits,
grantedBy,
session,
role._id,
);
} catch (error) {
logger.error(`[PermissionService.grantPermission] Error: ${error.message}`);
throw error;
}
};
/**
* Check if a user has specific permission bits on a resource
* @param {Object} params - Parameters for checking permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether the user has the required permission bits
*/
const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => {
try {
if (typeof requiredPermission !== 'number' || requiredPermission < 1) {
throw new Error('requiredPermission must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return false;
}
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
} catch (error) {
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermission must be')) {
throw error;
}
return false;
}
};
/**
* Get effective permission bitmask for a user on a resource
* @param {Object} params - Parameters for getting effective permissions
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @returns {Promise<number>} Effective permission bitmask
*/
const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => {
try {
// Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals(userId);
if (principals.length === 0) {
return 0;
}
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
} catch (error) {
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
return 0;
}
};
/**
* Find all resources of a specific type that a user has access to with specific permission bits
* @param {Object} params - Parameters for finding accessible resources
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Get all principals for the user (user + groups + public)
const principalsList = await getUserPrincipals(userId);
if (principalsList.length === 0) {
return [];
}
return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Find all publicly accessible resources of a specific type
* @param {Object} params - Parameters for finding publicly accessible resources
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<Array>} Array of resource IDs
*/
const findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Find all public ACL entries where the public principal has at least the required permission bits
const entries = await AclEntry.find({
principalType: 'public',
resourceType,
permBits: { $bitsAllSet: requiredPermissions },
}).distinct('resourceId');
return entries;
} catch (error) {
logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return [];
}
};
/**
* Get available roles for a resource type
* @param {Object} params - Parameters for getting available roles
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @returns {Promise<Array>} Array of role definitions
*/
const getAvailableRoles = async ({ resourceType }) => {
try {
return await AccessRole.find({ resourceType }).lean();
} catch (error) {
logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`);
return [];
}
};
/**
* Ensures a principal exists in the database based on TPrincipal data
* Creates user if it doesn't exist locally (for Entra ID users)
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - 'user', 'group', or 'public'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @returns {Promise<string|null>} Returns the principalId for database operations, null for public
*/
const ensurePrincipalExists = async function (principal) {
if (principal.type === 'public') {
return null;
}
if (principal.id) {
return principal.id;
}
if (principal.type === 'user' && principal.source === 'entra') {
if (!principal.email || !principal.idOnTheSource) {
throw new Error('Entra ID user principals must have email and idOnTheSource');
}
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
if (!existingUser) {
existingUser = await findUser({ email: principal.email.toLowerCase() });
}
if (existingUser) {
if (!existingUser.idOnTheSource && principal.idOnTheSource) {
await updateUser(existingUser._id, {
idOnTheSource: principal.idOnTheSource,
provider: 'openid',
});
}
return existingUser._id.toString();
}
const userData = {
name: principal.name,
email: principal.email.toLowerCase(),
emailVerified: false,
provider: 'openid',
idOnTheSource: principal.idOnTheSource,
};
const userId = await createUser(userData, true, false);
return userId.toString();
}
if (principal.type === 'group') {
throw new Error('Group principals should be handled by group-specific methods');
}
throw new Error(`Unsupported principal type: ${principal.type}`);
};
/**
* Ensures a group principal exists in the database based on TPrincipal data
* Creates group if it doesn't exist locally (for Entra ID groups)
* For Entra ID groups, always synchronizes member IDs when authentication context is provided
* @param {Object} principal - TPrincipal object from frontend
* @param {string} principal.type - Must be 'group'
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
* @param {string} principal.name - Display name
* @param {string} [principal.email] - Email address
* @param {string} [principal.description] - Group description
* @param {string} [principal.source] - 'local' or 'entra'
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
* @param {Object} [authContext] - Optional authentication context for fetching member data
* @param {string} [authContext.accessToken] - Access token for Graph API calls
* @param {string} [authContext.sub] - Subject identifier
* @returns {Promise<string>} Returns the groupId for database operations
*/
const ensureGroupPrincipalExists = async function (principal, authContext = null) {
if (principal.type !== 'group') {
throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`);
}
if (principal.source === 'entra') {
if (!principal.name || !principal.idOnTheSource) {
throw new Error('Entra ID group principals must have name and idOnTheSource');
}
let memberIds = [];
if (authContext && authContext.accessToken && authContext.sub) {
try {
memberIds = await getGroupMembers(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
// Include group owners as members if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownerIds = await getGroupOwners(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
if (ownerIds && ownerIds.length > 0) {
memberIds.push(...ownerIds);
// Remove duplicates
memberIds = [...new Set(memberIds)];
}
}
} catch (error) {
logger.error('Failed to fetch group members from Graph API:', error);
}
}
let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra');
if (!existingGroup && principal.email) {
existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean();
}
if (existingGroup) {
const updateData = {};
let needsUpdate = false;
if (!existingGroup.idOnTheSource && principal.idOnTheSource) {
updateData.idOnTheSource = principal.idOnTheSource;
updateData.source = 'entra';
needsUpdate = true;
}
if (principal.description && existingGroup.description !== principal.description) {
updateData.description = principal.description;
needsUpdate = true;
}
if (principal.email && existingGroup.email !== principal.email.toLowerCase()) {
updateData.email = principal.email.toLowerCase();
needsUpdate = true;
}
if (authContext && authContext.accessToken && authContext.sub) {
updateData.memberIds = memberIds;
needsUpdate = true;
}
if (needsUpdate) {
await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true });
}
return existingGroup._id.toString();
}
const groupData = {
name: principal.name,
source: 'entra',
idOnTheSource: principal.idOnTheSource,
memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context)
};
if (principal.email) {
groupData.email = principal.email.toLowerCase();
}
if (principal.description) {
groupData.description = principal.description;
}
const newGroup = await createGroup(groupData);
return newGroup._id.toString();
}
if (principal.id && authContext == null) {
return principal.id;
}
throw new Error(`Unsupported group principal source: ${principal.source}`);
};
/**
* Synchronize user's Entra ID group memberships on sign-in
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
* @param {Object} user - User object with authentication context
* @param {string} user.openidId - User's OpenID subject identifier
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
* @param {string} user.provider - Authentication provider ('openid')
* @param {string} accessToken - Access token for Graph API calls
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
* @returns {Promise<void>}
*/
const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => {
try {
if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) {
return;
}
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
let allGroupIds = [...(memberGroupIds || [])];
// Include owned groups if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
if (ownedGroupIds && ownedGroupIds.length > 0) {
allGroupIds.push(...ownedGroupIds);
// Remove duplicates
allGroupIds = [...new Set(allGroupIds)];
}
}
if (!allGroupIds || allGroupIds.length === 0) {
return;
}
const sessionOptions = session ? { session } : {};
await Group.updateMany(
{
idOnTheSource: { $in: allGroupIds },
source: 'entra',
memberIds: { $ne: user.idOnTheSource },
},
{ $addToSet: { memberIds: user.idOnTheSource } },
sessionOptions,
);
await Group.updateMany(
{
source: 'entra',
memberIds: user.idOnTheSource,
idOnTheSource: { $nin: allGroupIds },
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,
);
} catch (error) {
logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error);
}
};
/**
* Check if public has a specific permission on a resource
* @param {Object} params - Parameters for checking public permission
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
* @returns {Promise<boolean>} Whether public has the required permission bits
*/
const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => {
try {
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
throw new Error('requiredPermissions must be a positive number');
}
// Use public principal to check permissions
const publicPrincipal = [{ principalType: 'public' }];
const entries = await findEntriesByPrincipalsAndResource(
publicPrincipal,
resourceType,
resourceId,
);
// Check if any entry has the required permission bits
return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions);
} catch (error) {
logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`);
// Re-throw validation errors
if (error.message.includes('requiredPermissions must be')) {
throw error;
}
return false;
}
};
/**
* Bulk update permissions for a resource (grant, update, revoke)
* Efficiently handles multiple permission changes in a single transaction
*
* @param {Object} params - Parameters for bulk permission update
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
* @param {Array<TPrincipal>} params.updatedPrincipals - Array of principals to grant/update permissions for
* @param {Array<TPrincipal>} params.revokedPrincipals - Array of principals to revoke permissions from
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
* @returns {Promise<Object>} Results object with granted, updated, revoked arrays and error details
*/
const bulkUpdateResourcePermissions = async ({
resourceType,
resourceId,
updatedPrincipals = [],
revokedPrincipals = [],
grantedBy,
session,
}) => {
const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache);
transactionSupportCache = supportsTransactions;
let localSession = session;
let shouldEndSession = false;
try {
if (!Array.isArray(updatedPrincipals)) {
throw new Error('updatedPrincipals must be an array');
}
if (!Array.isArray(revokedPrincipals)) {
throw new Error('revokedPrincipals must be an array');
}
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
throw new Error(`Invalid resource ID: ${resourceId}`);
}
if (!localSession && supportsTransactions) {
localSession = await mongoose.startSession();
localSession.startTransaction();
shouldEndSession = true;
}
const sessionOptions = localSession ? { session: localSession } : {};
const roles = await AccessRole.find({ resourceType }).lean();
const rolesMap = new Map();
roles.forEach((role) => {
rolesMap.set(role.accessRoleId, role);
});
const results = {
granted: [],
updated: [],
revoked: [],
errors: [],
};
const bulkWrites = [];
for (const principal of updatedPrincipals) {
try {
if (!principal.accessRoleId) {
results.errors.push({
principal,
error: 'accessRoleId is required for updated principals',
});
continue;
}
const role = rolesMap.get(principal.accessRoleId);
if (!role) {
results.errors.push({
principal,
error: `Role ${principal.accessRoleId} not found`,
});
continue;
}
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
const update = {
$set: {
permBits: role.permBits,
roleId: role._id,
grantedBy,
grantedAt: new Date(),
},
$setOnInsert: {
principalType: principal.type,
resourceType,
resourceId,
...(principal.type !== 'public' && {
principalId: principal.id,
principalModel: principal.type === 'user' ? 'User' : 'Group',
}),
},
};
bulkWrites.push({
updateOne: {
filter: query,
update: update,
upsert: true,
},
});
results.granted.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
accessRoleId: principal.accessRoleId,
memberCount: principal.memberCount,
memberIds: principal.memberIds,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (bulkWrites.length > 0) {
await AclEntry.bulkWrite(bulkWrites, sessionOptions);
}
const deleteQueries = [];
for (const principal of revokedPrincipals) {
try {
const query = {
principalType: principal.type,
resourceType,
resourceId,
};
if (principal.type !== 'public') {
query.principalId = principal.id;
}
deleteQueries.push(query);
results.revoked.push({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
idOnTheSource: principal.idOnTheSource,
memberCount: principal.memberCount,
});
} catch (error) {
results.errors.push({
principal,
error: error.message,
});
}
}
if (deleteQueries.length > 0) {
await AclEntry.deleteMany(
{
$or: deleteQueries,
},
sessionOptions,
);
}
if (shouldEndSession && supportsTransactions) {
await localSession.commitTransaction();
}
return results;
} catch (error) {
if (shouldEndSession && supportsTransactions) {
await localSession.abortTransaction();
}
logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`);
throw error;
} finally {
if (shouldEndSession && localSession) {
localSession.endSession();
}
}
};
module.exports = {
grantPermission,
checkPermission,
getEffectivePermissions,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
getAvailableRoles,
bulkUpdateResourcePermissions,
ensurePrincipalExists,
ensureGroupPrincipalExists,
syncUserEntraGroupMemberships,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,3 @@
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Constants,
StepTypes,
@@ -11,8 +8,9 @@ const {
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { processMessages } = require('~/server/services/Threads');
const { createOnProgress } = require('~/server/utils');
const { logger } = require('~/config');
/**
* Implements the StreamRunManager functionality for managing the streaming
@@ -128,7 +126,7 @@ class StreamRunManager {
conversationId: this.finalMessage.conversationId,
};
sendEvent(this.res, contentData);
sendMessage(this.res, contentData);
}
/* <------------------ Misc. Helpers ------------------> */
@@ -304,7 +302,7 @@ class StreamRunManager {
for (const d of delta[key]) {
if (typeof d === 'object' && !Object.prototype.hasOwnProperty.call(d, 'index')) {
logger.warn("Expected an object with an 'index' for array updates but got:", d);
logger.warn('Expected an object with an \'index\' for array updates but got:', d);
continue;
}

View File

@@ -7,9 +7,9 @@ const {
defaultAssistantsVersion,
defaultAgentCapabilities,
} = require('librechat-data-provider');
const { sendEvent } = require('@librechat/api');
const { Providers } = require('@librechat/agents');
const partialRight = require('lodash/partialRight');
const { sendMessage } = require('./streamResponse');
/** Helper function to escape special characters in regex
* @param {string} string - The string to escape.
@@ -37,7 +37,7 @@ const createOnProgress = (
basePayload.text = basePayload.text + chunk;
const payload = Object.assign({}, basePayload, rest);
sendEvent(res, payload);
sendMessage(res, payload);
if (_onProgress) {
_onProgress(payload);
}
@@ -50,7 +50,7 @@ const createOnProgress = (
const sendIntermediateMessage = (res, payload, extraTokens = '') => {
basePayload.text = basePayload.text + extraTokens;
const message = Object.assign({}, basePayload, payload);
sendEvent(res, message);
sendMessage(res, message);
if (i === 0) {
basePayload.initial = false;
}

View File

@@ -1,9 +1,11 @@
const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* Check if email configuration is set
@@ -26,6 +28,7 @@ function checkEmailConfig() {
}
module.exports = {
...streamResponse,
checkEmailConfig,
...handleText,
countTokens,
@@ -33,4 +36,5 @@ module.exports = {
sendEmail,
...files,
...queue,
math,
};

View File

@@ -5,14 +5,14 @@
* If the input is not a string or contains invalid characters, an error is thrown.
* If the evaluated result is not a number, an error is thrown.
*
* @param str - The mathematical expression to evaluate, or a number.
* @param fallbackValue - The default value to return if the input is not a string or number, or if the evaluated result is not a number.
* @param {string|number} str - The mathematical expression to evaluate, or a number.
* @param {number} [fallbackValue] - The default value to return if the input is not a string or number, or if the evaluated result is not a number.
*
* @returns The result of the evaluated expression or the input number.
* @returns {number} The result of the evaluated expression or the input number.
*
* @throws Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number.
* @throws {Error} Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number.
*/
export function math(str: string | number, fallbackValue?: number): number {
function math(str, fallbackValue) {
const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number';
if (typeof str !== 'string' && typeof str === 'number') {
return str;
@@ -43,3 +43,5 @@ export function math(str: string | number, fallbackValue?: number): number {
return value;
}
module.exports = math;

View File

@@ -1,9 +1,31 @@
const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const { logger } = require('~/config');
/**
* Sends error data in Server Sent Events format and ends the response.
* @param {object} res - The server response.
* @param {string} message - The error message.
*/
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
};
/**
* Sends message data in Server Sent Events format.
* @param {Express.Response} res - - The server response.
* @param {string | Object} message - The message to be sent.
* @param {'message' | 'error' | 'cancel'} event - [Optional] The type of event. Default is 'message'.
*/
const sendMessage = (res, message, event = 'message') => {
if (typeof message === 'string' && message.length === 0) {
return;
}
res.write(`event: ${event}\ndata: ${JSON.stringify(message)}\n\n`);
};
/**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
@@ -69,7 +91,7 @@ const sendError = async (req, res, options, callback) => {
convo = parseConvo(errorMessage);
}
return sendEvent(res, {
return sendMessage(res, {
final: true,
requestMessage: query?.[0] ? query[0] : requestMessage,
responseMessage: errorMessage,
@@ -98,10 +120,12 @@ const sendResponse = (req, res, data, errorMessage) => {
if (errorMessage) {
return sendError(req, res, { ...data, text: errorMessage });
}
return sendEvent(res, data);
return sendMessage(res, data);
};
module.exports = {
sendError,
sendResponse,
handleError,
sendMessage,
sendError,
};

View File

@@ -118,7 +118,7 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
@@ -130,7 +130,7 @@ const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache =
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
@@ -365,7 +365,6 @@ async function setupOpenId() {
email: userinfo.email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
};
const balanceConfig = await getBalanceConfig();
@@ -376,7 +375,6 @@ async function setupOpenId() {
user.openidId = userinfo.sub;
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {

View File

@@ -1,9 +1,11 @@
const loadYaml = require('./loadYaml');
const tokenHelpers = require('./tokens');
const deriveBaseURL = require('./deriveBaseURL');
const extractBaseURL = require('./extractBaseURL');
const findMessageContent = require('./findMessageContent');
module.exports = {
loadYaml,
deriveBaseURL,
extractBaseURL,
...tokenHelpers,

13
api/utils/loadYaml.js Normal file
View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const yaml = require('js-yaml');
function loadYaml(filepath) {
try {
let fileContents = fs.readFileSync(filepath, 'utf8');
return yaml.load(fileContents);
} catch (e) {
return e;
}
}
module.exports = loadYaml;

BIN
bun.lockb

Binary file not shown.

View File

@@ -4,7 +4,6 @@
"description": "",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"data-provider": "cd .. && npm run build:data-provider",
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",
@@ -110,6 +109,9 @@
"tailwindcss-radix": "^2.8.0",
"zod": "^3.22.4"
},
"peerDependencies": {
"jotai": "^2.12.5"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.22.15",
"@babel/preset-env": "^7.22.15",

View File

@@ -1,37 +0,0 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface ActivePanelContextType {
active: string | undefined;
setActive: (id: string) => void;
}
const ActivePanelContext = createContext<ActivePanelContextType | undefined>(undefined);
export function ActivePanelProvider({
children,
defaultActive,
}: {
children: ReactNode;
defaultActive?: string;
}) {
const [active, _setActive] = useState<string | undefined>(defaultActive);
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id);
_setActive(id);
};
return (
<ActivePanelContext.Provider value={{ active, setActive }}>
{children}
</ActivePanelContext.Provider>
);
}
export function useActivePanel() {
const context = useContext(ActivePanelContext);
if (context === undefined) {
throw new Error('useActivePanel must be used within an ActivePanelProvider');
}
return context;
}

View File

@@ -40,40 +40,41 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
agent_id: agent_id || '',
})) || [];
const groupedTools = tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
const groupedTools =
tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
) || {};
const value = {
action,

View File

@@ -1,7 +1,6 @@
export { default as AssistantsProvider } from './AssistantsContext';
export { default as AgentsProvider } from './AgentsContext';
export { default as ToastProvider } from './ToastContext';
export * from './ActivePanelContext';
export * from './AgentPanelContext';
export * from './ChatContext';
export * from './ShareContext';

View File

@@ -1,10 +1,5 @@
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
import type {
Agent,
AgentProvider,
AgentModelParameters,
SupportContact,
} from 'librechat-data-provider';
import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider';
import type { OptionWithIcon, ExtendedFile } from './types';
export type TAgentOption = OptionWithIcon &
@@ -12,7 +7,6 @@ export type TAgentOption = OptionWithIcon &
knowledge_files?: Array<[string, ExtendedFile]>;
context_files?: Array<[string, ExtendedFile]>;
code_files?: Array<[string, ExtendedFile]>;
_id?: string;
};
export type TAgentCapabilities = {
@@ -36,6 +30,4 @@ export type AgentForm = {
agent_ids?: string[];
[AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number;
support_contact?: SupportContact;
category: string;
} & TAgentCapabilities;

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type RenderProp<
P = React.HTMLAttributes<any> & {
ref?: React.Ref<any>;

View File

@@ -219,11 +219,11 @@ export type AgentPanelContextType = {
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
agent_id?: string;
};

View File

@@ -16,7 +16,6 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],

View File

@@ -4,9 +4,9 @@ import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
@@ -14,25 +14,22 @@ import { useChatContext } from '~/Providers';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return (
<AttachFileMenu
disabled={disableInputs}
conversationId={conversationId}
endpointFileConfig={endpointFileConfig}
/>
);
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />;
}
return null;

View File

@@ -2,7 +2,6 @@ import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
@@ -13,10 +12,9 @@ import { cn } from '~/utils';
interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
}
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
@@ -26,7 +24,6 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
/** TODO: Ephemeral Agent Capabilities

View File

@@ -1,7 +1,6 @@
import React, { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, OGDialog, Button } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '~/components';
import { useLocalize } from '~/hooks';
export interface ConfigFieldDetail {

View File

@@ -3,7 +3,7 @@ import { SettingsIcon } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import MCPConfigDialog, { type ConfigFieldDetail } from './MCPConfigDialog';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import MultiSelect from '~/components/ui/MultiSelect';
import { MCPIcon } from '~/components/svg';

View File

@@ -17,7 +17,7 @@ export default function OpenSidebar({
variant="outline"
data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View File

@@ -12,7 +12,6 @@ import {
InputNumber,
SelectDropDown,
HoverCardTrigger,
MultiSelectDropDown,
} from '~/components/ui';
import {
removeFocusOutlines,
@@ -25,6 +24,7 @@ import {
} from '~/utils';
import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover';
import { useLocalize, useDebouncedInput } from '~/hooks';
import MultiSelectDropDown from '~/components/Input/ModelSelect/MultiSelectDropDown';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
import store from '~/store';
@@ -165,7 +165,7 @@ export default function Settings({
)}
className={cn(
defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
)}
/>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import { Wrench, ArrowRight } from 'lucide-react';
import {
Listbox,
ListboxButton,
@@ -7,12 +8,11 @@ import {
ListboxOption,
Transition,
} from '@headlessui/react';
import { Wrench, ArrowRight } from 'lucide-react';
import { CheckMark } from '~/components/svg';
import useOnClickOutside from '~/hooks/useOnClickOutside';
import { useMultiSearch } from './MultiSearch';
import { cn } from '~/utils/';
import type { TPlugin } from 'librechat-data-provider';
import { useMultiSearch } from '~/components/MultiSearch';
import { useOnClickOutside } from '~/hooks';
import { CheckMark } from '~/svgs';
import { cn } from '~/utils/';
export type TMultiSelectDropDownProps = {
title?: string;
@@ -142,7 +142,7 @@ function MultiSelectDropDown({
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
className="h-4 w-4 text-gray-400"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -2,7 +2,7 @@ import { Wrench } from 'lucide-react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import type { TPlugin } from 'librechat-data-provider';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import { useMultiSearch } from './MultiSearch';
import { useMultiSearch } from '~/components';
import { cn } from '~/utils/';
type SelectDropDownProps = {
@@ -32,8 +32,6 @@ function MultiSelectPop({
optionValueKey = 'value',
searchPlaceholder,
}: SelectDropDownProps) {
// const localize = useLocalize();
const title = _title;
const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins'];
@@ -54,14 +52,14 @@ function MultiSelectPop({
<button
data-testid="select-dropdown-button"
className={cn(
'relative flex flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 dark:bg-gray-800 sm:text-sm',
'relative flex flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
'pointer-cursor font-normal',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
)}
>
{' '}
{showLabel && (
<label className="block text-xs text-gray-700 dark:text-gray-500 ">{title}</label>
<label className="block text-xs text-gray-700 dark:text-gray-500">{title}</label>
)}
<span className="inline-flex" id={excludeIds[2]}>
<span
@@ -73,7 +71,7 @@ function MultiSelectPop({
{/* {!showLabel && title.length > 0 && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)} */}
<span className="flex items-center gap-1 ">
<span className="flex items-center gap-1">
<div className="flex gap-1">
{value.length === 0 && 'None selected'}
{value.map((v, i) => (
@@ -98,7 +96,7 @@ function MultiSelectPop({
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
className="h-4 w-4 text-gray-400"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -4,15 +4,10 @@ import { useState, useEffect, useMemo } from 'react';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import {
Button,
MultiSelectPop,
SelectDropDown,
SelectDropDownPop,
MultiSelectDropDown,
} from '~/components/ui';
import { Button, SelectDropDown, SelectDropDownPop, MultiSelectDropDown } from '~/components/ui';
import { useSetIndexOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks';
import { cn, cardStyle, selectPlugins, processPlugins } from '~/utils';
import MultiSelectPop from './MultiSelectPop';
import store from '~/store';
export default function PluginsByIndex({

View File

@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import React from 'react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import { useMultiSearch } from '~/components';
import type { Option } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import { useMultiSearch } from './MultiSearch';
type SelectDropDownProps = {
id?: string;
@@ -32,7 +32,6 @@ function SelectDropDownPop({
footer,
}: SelectDropDownProps) {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const transitionProps = { className: 'top-full mt-3' };
if (showAbove) {
transitionProps.className = 'bottom-full mb-3';
@@ -55,13 +54,8 @@ function SelectDropDownPop({
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;
const handleSelect = (selectedValue: string) => {
setValue(selectedValue);
setOpen(false);
};
return (
<Root open={open} onOpenChange={setOpen}>
<Root>
<div className={'flex items-center justify-center gap-2'}>
<div className={'relative w-full'}>
<Trigger asChild>
@@ -114,32 +108,19 @@ function SelectDropDownPop({
side="bottom"
align="start"
className={cn(
'z-50 mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
'mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
hasSearchRender && 'relative',
)}
>
{searchRender}
{options.map((option) => {
if (typeof option === 'string') {
return (
<MenuItem
key={option}
title={option}
value={option}
selected={!!(value && value === option)}
onClick={() => handleSelect(option)}
/>
);
}
return (
<MenuItem
key={option.value}
title={option.label}
description={option.description}
value={option.value}
icon={option.icon}
selected={!!(value && value === option.value)}
onClick={() => handleSelect(option.value)}
key={option}
title={option}
value={option}
selected={!!(value && value === option)}
onClick={() => setValue(option)}
/>
);
})}

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useContext } from 'react';
import React, { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo, useHasAccess } from '~/hooks';
import { AuthContext } from '~/hooks/AuthContext';
import { LayoutGrid } from 'lucide-react';
import { useLocalize, useNewConvo } from '~/hooks';
import store from '~/store';
export default function NewChat({
@@ -29,11 +29,6 @@ export default function NewChat({
const navigate = useNavigate();
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const authContext = useContext(AuthContext);
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
@@ -55,22 +50,6 @@ export default function NewChat({
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
);
const handleAgentMarketplace = useCallback(() => {
navigate('/agents');
if (isSmallScreen) {
toggleNav();
}
}, [navigate, isSmallScreen, toggleNav]);
// Check if auth is ready (avoid race conditions)
const authReady =
authContext?.isAuthenticated !== undefined &&
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
// Show agent marketplace when auth is ready and user has access
// Note: endpointsConfig[agents] is null, but we can still show the marketplace
const showAgentMarketplace = authReady && hasAccessToAgents;
return (
<>
<div className="flex items-center justify-between py-[2px] md:py-2">
@@ -109,29 +88,6 @@ export default function NewChat({
/>
</div>
</div>
{/* Agent Marketplace button - separate row like ChatGPT */}
{showAgentMarketplace && (
<div className="flex px-2 pb-4 pt-2 md:px-3">
<TooltipAnchor
description={localize('com_nav_agents_marketplace')}
render={
<Button
variant="outline"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_nav_agents_marketplace')}
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
onClick={handleAgentMarketplace}
>
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-base font-medium">
{localize('com_nav_agents_marketplace')}
</span>
</Button>
}
/>
</div>
)}
{subHeaders != null ? subHeaders : null}
</>
);

View File

@@ -1,26 +1,28 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTemplate,
OGDialogTrigger,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
TooltipAnchor,
DataTable,
Spinner,
Button,
Label,
Spinner,
} from '~/components';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useMediaQuery } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import store from '~/store';
const PAGE_SIZE = 25;
@@ -36,6 +38,7 @@ export default function SharedLinks() {
const localize = useLocalize();
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const isSearchEnabled = useRecoilValue(store.search);
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
@@ -308,6 +311,7 @@ export default function SharedLinks() {
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
isLoading={isLoading}
enableSearch={isSearchEnabled}
/>
</OGDialogContent>
</OGDialog>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import {
@@ -11,6 +12,7 @@ import {
Label,
TooltipAnchor,
Spinner,
DataTable,
} from '~/components';
import {
useArchiveConvoMutation,
@@ -19,10 +21,10 @@ import {
} from '~/data-provider';
import { useLocalize, useMediaQuery } from '~/hooks';
import { MinimalIcon } from '~/components/Endpoints';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import store from '~/store';
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
@@ -39,7 +41,7 @@ export default function ArchivedChatsTable({
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { showToast } = useToastContext();
const isSearchEnabled = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
@@ -272,6 +274,7 @@ export default function ArchivedChatsTable({
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={isSearchEnabled}
/>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>

View File

@@ -9,10 +9,6 @@ import {
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
// NEW: Add these for agent categories
Users as UsersIcon,
Beaker as BeakerIcon,
Settings as SettingsIcon,
} from 'lucide-react';
import { cn } from '~/utils';
@@ -26,13 +22,6 @@ const categoryIconMap: Record<string, React.ElementType> = {
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
// NEW: Agent categories
general: BoxIcon,
hr: UsersIcon,
rd: BeakerIcon,
it: TerminalSquareIcon,
sales: LineChartIcon,
aftersales: SettingsIcon,
};
const categoryColorMap: Record<string, string> = {
@@ -45,13 +34,6 @@ const categoryColorMap: Record<string, string> = {
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
// NEW: Agent categories
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
it: 'text-red-500',
sales: 'text-orange-500',
aftersales: 'text-yellow-500',
};
export default function CategoryIcon({

View File

@@ -14,12 +14,7 @@ import type {
AgentCreateParams,
AgentListResponse,
} from 'librechat-data-provider';
import {
useUploadAgentAvatarMutation,
useGetFileConfig,
allAgentViewAndEditQueryKeys,
invalidateAgentMarketplaceQueries,
} from '~/data-provider';
import { useUploadAgentAvatarMutation, useGetFileConfig } from '~/data-provider';
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
@@ -62,31 +57,30 @@ function Avatar({
const newUrl = data.avatar?.filepath ?? '';
setPreviewUrl(newUrl);
((keys) => {
keys.forEach((key) => {
const res = queryClient.getQueryData<AgentListResponse>([QueryKeys.agents, key]);
const res = queryClient.getQueryData<AgentListResponse>([
QueryKeys.agents,
defaultOrderQuery,
]);
if (!res?.data) {
return;
}
if (!res?.data) {
return;
}
const agents = res.data.map((agent) => {
if (agent.id === agent_id) {
return {
...agent,
...data,
};
}
return agent;
});
const agents = res.data.map((agent) => {
if (agent.id === agent_id) {
return {
...agent,
...data,
};
}
return agent;
});
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
...res,
data: agents,
});
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, key], {
...res,
data: agents,
});
});
})(allAgentViewAndEditQueryKeys);
invalidateAgentMarketplaceQueries(queryClient);
setProgress(1);
},
onError: (error) => {

View File

@@ -1,93 +0,0 @@
import React from 'react';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
import { cn } from '~/utils';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
onClick: () => void; // Callback when card is clicked
className?: string; // Additional CSS classes
}
/**
* Card component to display agent information
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
return (
<div
className={cn(
'group relative flex overflow-hidden rounded-2xl',
'cursor-pointer transition-colors duration-200',
'aspect-[5/2.5] w-full',
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
className,
)}
onClick={onClick}
aria-label={localize('com_agents_agent_card_label', {
name: agent.name,
description: agent.description || localize('com_agents_no_description'),
})}
aria-describedby={`agent-${agent.id}-description`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
{/* Agent avatar section - left side, responsive */}
<div className="flex flex-shrink-0 items-center">
{renderAgentAvatar(agent, { size: 'md' })}
</div>
{/* Agent info section - right side, responsive */}
<div className="flex min-w-0 flex-1 flex-col justify-center">
{/* Agent name - responsive text sizing */}
<h3 className="mb-1 line-clamp-1 text-base font-bold text-gray-900 dark:text-white sm:mb-2 sm:text-lg">
{agent.name}
</h3>
{/* Agent description - responsive text sizing and spacing */}
<p
id={`agent-${agent.id}-description`}
className={cn(
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300',
'sm:mb-2 sm:text-sm',
)}
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
>
{agent.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
)}
</p>
{/* Owner info - responsive text sizing */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
<span className="font-light">{localize('com_agents_created_by')}</span>
<span className="ml-1 font-bold">{displayName}</span>
</div>
);
}
return null;
})()}
</div>
</div>
</div>
);
};
export default AgentCard;

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
interface AgentCategoryDisplayProps {
category?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
showEmptyFallback?: boolean;
}
/**
* Component to display an agent category with proper translation
*
* @param category - The category value (e.g., "general", "hr", etc.)
* @param className - Optional className for the container
* @param showIcon - Whether to show the category icon
* @param iconClassName - Optional className for the icon
* @param showEmptyFallback - Whether to show a fallback for empty categories
*/
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
category,
className = '',
showIcon = true,
iconClassName = 'h-4 w-4 mr-2',
showEmptyFallback = false,
}) => {
const { categories, emptyCategory } = useAgentCategories();
// Find the category in our processed categories list
const categoryItem = categories.find((c) => c.value === category);
// Handle empty string case differently than undefined/null
if (category === '') {
if (!showEmptyFallback) {
return null;
}
// Show the empty category placeholder
return (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

Some files were not shown because too many files have changed in this diff Show More