🗑️ chore: Remove Deprecated Project Model and Associated Fields (#11773)

* chore: remove projects and projectIds usage

* chore: empty line linting

* chore: remove isCollaborative property across agent models and related tests

- Removed the isCollaborative property from agent models, controllers, and tests, as it is deprecated in favor of ACL permissions.
- Updated related validation schemas and data provider types to reflect this change.
- Ensured all references to isCollaborative were stripped from the codebase to maintain consistency and clarity.
This commit is contained in:
Danny Avila 2026-02-13 03:04:15 -05:00
parent 3398f6a17a
commit 37cc5faff5
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
41 changed files with 94 additions and 821 deletions

View file

@ -4,7 +4,6 @@ const { logger } = require('@librechat/data-schemas');
const { getCustomEndpointConfig } = require('@librechat/api');
const {
Tools,
SystemRoles,
ResourceType,
actionDelimiter,
isAgentsEndpoint,
@ -12,11 +11,6 @@ const {
encodeEphemeralAgentId,
} = require('librechat-data-provider');
const { mcp_all, mcp_delimiter } = require('librechat-data-provider').Constants;
const {
removeAgentFromAllProjects,
removeAgentIdsFromProject,
addAgentIdsToProject,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry, User } = require('~/db/models');
@ -291,22 +285,8 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
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();
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
) {
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();
@ -587,7 +567,6 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => {
const deleteAgent = async (searchParameter) => {
const agent = await Agent.findOneAndDelete(searchParameter);
if (agent) {
await removeAgentFromAllProjects(agent.id);
await Promise.all([
removeAllPermissions({
resourceType: ResourceType.AGENT,
@ -631,10 +610,6 @@ const deleteUserAgents = async (userId) => {
const agentIds = userAgents.map((agent) => agent.id);
const agentObjectIds = userAgents.map((agent) => agent._id);
for (const agentId of agentIds) {
await removeAgentFromAllProjects(agentId);
}
await AclEntry.deleteMany({
resourceType: { $in: [ResourceType.AGENT, ResourceType.REMOTE_AGENT] },
resourceId: { $in: agentObjectIds },
@ -710,7 +685,6 @@ const getListAgentsByAccess = async ({
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
@ -755,64 +729,6 @@ const getListAgentsByAccess = async ({
};
};
/**
* Updates the projects associated with an agent, adding and removing project IDs as specified.
* This function also updates the corresponding projects to include or exclude the agent ID.
*
* @param {Object} params - Parameters for updating the agent's projects.
* @param {IUser} params.user - Parameters for updating the agent's projects.
* @param {string} params.agentId - The ID of the agent to update.
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.
* @returns {Promise<MongoAgent>} The updated agent document.
* @throws {Error} If there's an error updating the agent or projects.
*/
const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds }) => {
const updateOps = {};
if (removeProjectIds && removeProjectIds.length > 0) {
for (const projectId of removeProjectIds) {
await removeAgentIdsFromProject(projectId, [agentId]);
}
updateOps.$pullAll = { projectIds: removeProjectIds };
}
if (projectIds && projectIds.length > 0) {
for (const projectId of projectIds) {
await addAgentIdsToProject(projectId, [agentId]);
}
updateOps.$addToSet = { projectIds: { $each: projectIds } };
}
if (Object.keys(updateOps).length === 0) {
return await getAgent({ id: agentId });
}
const updateQuery = { id: agentId, author: user.id };
if (user.role === SystemRoles.ADMIN) {
delete updateQuery.author;
}
const updatedAgent = await updateAgent(updateQuery, updateOps, {
updatingUserId: user.id,
skipVersioning: true,
});
if (updatedAgent) {
return updatedAgent;
}
if (updateOps.$addToSet) {
for (const projectId of projectIds) {
await removeAgentIdsFromProject(projectId, [agentId]);
}
} else if (updateOps.$pull) {
for (const projectId of removeProjectIds) {
await addAgentIdsToProject(projectId, [agentId]);
}
}
return await getAgent({ id: agentId });
};
/**
* Reverts an agent to a specific version in its version history.
* @param {Object} searchParameter - The search parameters to find the agent to revert.
@ -921,7 +837,6 @@ module.exports = {
deleteAgent,
deleteUserAgents,
revertAgentVersion,
updateAgentProjects,
countPromotedAgents,
addAgentResourceFile,
getListAgentsByAccess,

View file

@ -24,7 +24,6 @@ const {
deleteAgent,
deleteUserAgents,
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
@ -1060,53 +1059,6 @@ describe('models/Agent', () => {
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should update agent projects', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const projectId3 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Project Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
);
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
const updatedAgent = await getAgent({ id: agentId });
expect(updatedAgent.projectIds).toHaveLength(2);
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(
projectId1.toString(),
);
await updateAgent({ id: agentId }, { projectIds: [] });
const emptyProjectsAgent = await getAgent({ id: agentId });
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
const nonExistentId = `agent_${uuidv4()}`;
await expect(
updateAgentProjects({
id: nonExistentId,
projectIds: [projectId1],
}),
).rejects.toThrow();
});
test('should handle ephemeral agent loading', async () => {
const agentId = 'ephemeral_test';
const endpoint = 'openai';
@ -1178,20 +1130,6 @@ describe('models/Agent', () => {
const result = await fn();
expect(result).toBe(expected);
});
test('should handle updateAgentProjects with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const userId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
const result = await updateAgentProjects({
user: { id: userId.toString() },
agentId: nonExistentId,
projectIds: [projectId.toString()],
});
expect(result).toBeNull();
});
});
});
@ -1315,7 +1253,6 @@ describe('models/Agent', () => {
test('should handle MongoDB operators and field updates correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
@ -1331,7 +1268,6 @@ describe('models/Agent', () => {
{
description: 'Updated description',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId },
},
);
@ -1339,7 +1275,6 @@ describe('models/Agent', () => {
expect(firstUpdate.description).toBe('Updated description');
expect(firstUpdate.tools).toContain('tool1');
expect(firstUpdate.tools).toContain('tool2');
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(firstUpdate.versions).toHaveLength(2);
await updateAgent(
@ -1744,7 +1679,6 @@ describe('models/Agent', () => {
test('should handle version comparison with special field types', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
@ -1752,7 +1686,6 @@ describe('models/Agent', () => {
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId],
model_parameters: { temperature: 0.7 },
});
@ -2630,7 +2563,6 @@ describe('models/Agent', () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
@ -2638,7 +2570,6 @@ describe('models/Agent', () => {
provider: 'openai',
model: 'gpt-4',
author: authorId,
projectIds: [projectId],
});
const mockReq = { user: { id: userId.toString() } };
@ -2698,7 +2629,6 @@ describe('models/Agent', () => {
test('should handle agent creation with all optional fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
const agent = await createAgent({
id: agentId,
@ -2711,9 +2641,7 @@ describe('models/Agent', () => {
tools: ['tool1', 'tool2'],
actions: ['action1', 'action2'],
model_parameters: { temperature: 0.8, max_tokens: 1000 },
projectIds: [projectId],
avatar: 'https://example.com/avatar.png',
isCollaborative: true,
tool_resources: {
file_search: { file_ids: ['file1', 'file2'] },
},
@ -2727,9 +2655,7 @@ describe('models/Agent', () => {
expect(agent.actions).toEqual(['action1', 'action2']);
expect(agent.model_parameters.temperature).toBe(0.8);
expect(agent.model_parameters.max_tokens).toBe(1000);
expect(agent.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(agent.avatar).toBe('https://example.com/avatar.png');
expect(agent.isCollaborative).toBe(true);
expect(agent.tool_resources.file_search.file_ids).toEqual(['file1', 'file2']);
});
@ -2935,21 +2861,6 @@ describe('models/Agent', () => {
expect(finalAgent.name).toBe('Version 4');
});
test('should handle updateAgentProjects error scenarios', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const userId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
// Test with non-existent agent
const result = await updateAgentProjects({
user: { id: userId.toString() },
agentId: nonExistentId,
projectIds: [projectId.toString()],
});
expect(result).toBeNull();
});
test('should handle revertAgentVersion properly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
@ -3003,8 +2914,6 @@ describe('models/Agent', () => {
test('should handle updateAgent with combined MongoDB operators', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
@ -3013,7 +2922,6 @@ describe('models/Agent', () => {
model: 'test-model',
author: authorId,
tools: ['tool1'],
projectIds: [projectId1],
});
// Use multiple operators in single update - but avoid conflicting operations on same field
@ -3022,14 +2930,6 @@ describe('models/Agent', () => {
{
name: 'Updated Name',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId2 },
},
);
const finalAgent = await updateAgent(
{ id: agentId },
{
$pull: { projectIds: projectId1 },
},
);
@ -3037,11 +2937,7 @@ describe('models/Agent', () => {
expect(updatedAgent.name).toBe('Updated Name');
expect(updatedAgent.tools).toContain('tool1');
expect(updatedAgent.tools).toContain('tool2');
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(finalAgent).toBeDefined();
expect(finalAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
expect(finalAgent.versions).toHaveLength(3);
expect(updatedAgent.versions).toHaveLength(2);
});
test('should handle updateAgent when agent does not exist', async () => {
@ -3315,65 +3211,6 @@ describe('models/Agent', () => {
expect(updated2.description).toBe('Another description');
});
test('should skip version creation when skipVersioning option is used', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
// Create agent with initial projectIds
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
// Share agent using updateAgentProjects (which uses skipVersioning)
const shared = await updateAgentProjects({
user: { id: authorId.toString() }, // Use the same author ID
agentId: agentId,
projectIds: [projectId2.toString()],
});
// Should NOT create a new version due to skipVersioning
expect(shared.versions).toHaveLength(1);
expect(shared.projectIds.map((id) => id.toString())).toContain(projectId1.toString());
expect(shared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
// Unshare agent using updateAgentProjects
const unshared = await updateAgentProjects({
user: { id: authorId.toString() },
agentId: agentId,
removeProjectIds: [projectId1.toString()],
});
// Still should NOT create a new version
expect(unshared.versions).toHaveLength(1);
expect(unshared.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
expect(unshared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
// Regular update without skipVersioning should create a version
const regularUpdate = await updateAgent(
{ id: agentId },
{ description: 'Updated description' },
);
expect(regularUpdate.versions).toHaveLength(2);
expect(regularUpdate.description).toBe('Updated description');
// Direct updateAgent with MongoDB operators should still create versions
const directUpdate = await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId1] } } },
);
expect(directUpdate.versions).toHaveLength(3);
expect(directUpdate.projectIds.length).toBe(2);
});
test('should preserve agent_ids in version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
@ -3754,7 +3591,6 @@ function createTestIds() {
return {
agentId: `agent_${uuidv4()}`,
authorId: new mongoose.Types.ObjectId(),
projectId: new mongoose.Types.ObjectId(),
fileId: uuidv4(),
};
}
@ -3788,9 +3624,6 @@ function mockFindOneAndUpdateError(errorOnCall = 1) {
}
function generateVersionTestCases() {
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
return [
{
name: 'simple field update',
@ -3817,13 +3650,5 @@ function generateVersionTestCases() {
update: { tools: ['tool2', 'tool3'] },
duplicate: { tools: ['tool2', 'tool3'] },
},
{
name: 'projectIds update',
initial: {
projectIds: [projectId1],
},
update: { projectIds: [projectId1, projectId2] },
duplicate: { projectIds: [projectId2, projectId1] },
},
];
}

View file

@ -1,133 +0,0 @@
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { Project } = require('~/db/models');
/**
* Retrieve a project by ID and convert the found project document to a plain object.
*
* @param {string} projectId - The ID of the project to find and return as a plain object.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<IMongoProject>} A plain object representing the project document, or `null` if no project is found.
*/
const getProjectById = async function (projectId, fieldsToSelect = null) {
const query = Project.findById(projectId);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
* Retrieve a project by name and convert the found project document to a plain object.
* If the project with the given name doesn't exist and the name is "instance", create it and return the lean version.
*
* @param {string} projectName - The name of the project to find or create.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<IMongoProject>} A plain object representing the project document.
*/
const getProjectByName = async function (projectName, fieldsToSelect = null) {
const query = { name: projectName };
const update = { $setOnInsert: { name: projectName } };
const options = {
new: true,
upsert: projectName === GLOBAL_PROJECT_NAME,
lean: true,
select: fieldsToSelect,
};
return await Project.findOneAndUpdate(query, update, options);
};
/**
* Add an array of prompt group IDs to a project's promptGroupIds array, ensuring uniqueness.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} promptGroupIds - The array of prompt group IDs to add to the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const addGroupIdsToProject = async function (projectId, promptGroupIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $addToSet: { promptGroupIds: { $each: promptGroupIds } } },
{ new: true },
);
};
/**
* Remove an array of prompt group IDs from a project's promptGroupIds array.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} promptGroupIds - The array of prompt group IDs to remove from the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const removeGroupIdsFromProject = async function (projectId, promptGroupIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $pullAll: { promptGroupIds: promptGroupIds } },
{ new: true },
);
};
/**
* Remove a prompt group ID from all projects.
*
* @param {string} promptGroupId - The ID of the prompt group to remove from projects.
* @returns {Promise<void>}
*/
const removeGroupFromAllProjects = async (promptGroupId) => {
await Project.updateMany({}, { $pullAll: { promptGroupIds: [promptGroupId] } });
};
/**
* Add an array of agent IDs to a project's agentIds array, ensuring uniqueness.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} agentIds - The array of agent IDs to add to the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const addAgentIdsToProject = async function (projectId, agentIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $addToSet: { agentIds: { $each: agentIds } } },
{ new: true },
);
};
/**
* Remove an array of agent IDs from a project's agentIds array.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} agentIds - The array of agent IDs to remove from the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const removeAgentIdsFromProject = async function (projectId, agentIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $pullAll: { agentIds: agentIds } },
{ new: true },
);
};
/**
* Remove an agent ID from all projects.
*
* @param {string} agentId - The ID of the agent to remove from projects.
* @returns {Promise<void>}
*/
const removeAgentFromAllProjects = async (agentId) => {
await Project.updateMany({}, { $pullAll: { agentIds: [agentId] } });
};
module.exports = {
getProjectById,
getProjectByName,
/* prompts */
addGroupIdsToProject,
removeGroupIdsFromProject,
removeGroupFromAllProjects,
/* agents */
addAgentIdsToProject,
removeAgentIdsFromProject,
removeAgentFromAllProjects,
};

View file

@ -1,18 +1,7 @@
const { ObjectId } = require('mongodb');
const { escapeRegExp } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Constants,
SystemRoles,
ResourceType,
SystemCategories,
} = require('librechat-data-provider');
const {
removeGroupFromAllProjects,
removeGroupIdsFromProject,
addGroupIdsToProject,
getProjectByName,
} = require('./Project');
const { SystemRoles, ResourceType, SystemCategories } = require('librechat-data-provider');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
@ -48,34 +37,21 @@ const getAllPromptGroups = async (req, filter) => {
try {
const { name, ...query } = filter;
let searchShared = true;
let searchSharedOnly = false;
if (name) {
query.name = new RegExp(escapeRegExp(name), 'i');
}
if (!query.category) {
delete query.category;
} else if (query.category === SystemCategories.MY_PROMPTS) {
searchShared = false;
delete query.category;
} else if (query.category === SystemCategories.NO_CATEGORY) {
query.category = '';
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
searchSharedOnly = true;
delete query.category;
}
let combinedQuery = query;
if (searchShared) {
const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
delete projectQuery.author;
combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
}
}
const groups = await PromptGroup.find(combinedQuery)
.sort({ createdAt: -1 })
.select('name oneliner category author authorName createdAt updatedAt command productionId')
@ -100,34 +76,21 @@ const getPromptGroups = async (req, filter) => {
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
let searchShared = true;
let searchSharedOnly = false;
if (name) {
query.name = new RegExp(escapeRegExp(name), 'i');
}
if (!query.category) {
delete query.category;
} else if (query.category === SystemCategories.MY_PROMPTS) {
searchShared = false;
delete query.category;
} else if (query.category === SystemCategories.NO_CATEGORY) {
query.category = '';
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
searchSharedOnly = true;
delete query.category;
}
let combinedQuery = query;
if (searchShared) {
const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
delete projectQuery.author;
combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
}
}
const skip = (validatedPageNumber - 1) * validatedPageSize;
const limit = validatedPageSize;
@ -137,7 +100,7 @@ const getPromptGroups = async (req, filter) => {
.skip(skip)
.limit(limit)
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
)
.lean(),
PromptGroup.countDocuments(combinedQuery),
@ -182,7 +145,6 @@ const deletePromptGroup = async ({ _id, author, role }) => {
}
await Prompt.deleteMany(groupQuery);
await removeGroupFromAllProjects(_id);
try {
await removeAllPermissions({ resourceType: ResourceType.PROMPTGROUP, resourceId: _id });
@ -241,7 +203,7 @@ async function getListPromptGroupsByAccess({
const findQuery = PromptGroup.find(baseQuery)
.sort({ updatedAt: -1, _id: 1 })
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
);
if (isPaginated) {
@ -487,7 +449,6 @@ module.exports = {
}
await PromptGroup.deleteOne({ _id: groupId });
await removeGroupFromAllProjects(groupId);
return {
prompt: 'Prompt deleted successfully',
@ -523,10 +484,6 @@ module.exports = {
const groupIds = promptGroups.map((group) => group._id);
for (const groupId of groupIds) {
await removeGroupFromAllProjects(groupId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.PROMPTGROUP,
resourceId: { $in: groupIds },
@ -547,23 +504,6 @@ module.exports = {
updatePromptGroup: async (filter, data) => {
try {
const updateOps = {};
if (data.removeProjectIds) {
for (const projectId of data.removeProjectIds) {
await removeGroupIdsFromProject(projectId, [filter._id]);
}
updateOps.$pullAll = { projectIds: data.removeProjectIds };
delete data.removeProjectIds;
}
if (data.projectIds) {
for (const projectId of data.projectIds) {
await addGroupIdsToProject(projectId, [filter._id]);
}
updateOps.$addToSet = { projectIds: { $each: data.projectIds } };
delete data.projectIds;
}
const updateData = { ...data, ...updateOps };
const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, {

View file

@ -19,7 +19,7 @@ const dbModels = require('~/db/models');
logger.silent = true;
let mongoServer;
let Prompt, PromptGroup, AclEntry, AccessRole, User, Group, Project;
let Prompt, PromptGroup, AclEntry, AccessRole, User, Group;
let promptFns, permissionService;
let testUsers, testGroups, testRoles;
@ -36,7 +36,6 @@ beforeAll(async () => {
AccessRole = dbModels.AccessRole;
User = dbModels.User;
Group = dbModels.Group;
Project = dbModels.Project;
promptFns = require('~/models/Prompt');
permissionService = require('~/server/services/PermissionService');
@ -118,12 +117,6 @@ async function setupTestData() {
description: 'Group with viewer access',
}),
};
await Project.create({
name: 'Global',
description: 'Global project',
promptGroupIds: [],
});
}
describe('Prompt ACL Permissions', () => {

View file

@ -3,7 +3,6 @@ const { ObjectId } = require('mongodb');
const { logger } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
Constants,
ResourceType,
AccessRoleIds,
PrincipalType,
@ -19,9 +18,9 @@ logger.silent = true;
describe('PromptGroup Migration Script', () => {
let mongoServer;
let Prompt, PromptGroup, AclEntry, AccessRole, User, Project;
let Prompt, PromptGroup, AclEntry, AccessRole, User;
let migrateToPromptGroupPermissions;
let testOwner, testProject;
let testOwner;
let ownerRole, viewerRole;
beforeAll(async () => {
@ -37,7 +36,6 @@ describe('PromptGroup Migration Script', () => {
AclEntry = dbModels.AclEntry;
AccessRole = dbModels.AccessRole;
User = dbModels.User;
Project = dbModels.Project;
// Create test user
testOwner = await User.create({
@ -46,11 +44,10 @@ describe('PromptGroup Migration Script', () => {
role: 'USER',
});
// Create test project with the proper name
const projectName = Constants.GLOBAL_PROJECT_NAME || 'instance';
testProject = await Project.create({
// Create test project document in the raw `projects` collection
const projectName = 'instance';
await mongoose.connection.db.collection('projects').insertOne({
name: projectName,
description: 'Global project',
promptGroupIds: [],
});
@ -95,9 +92,9 @@ describe('PromptGroup Migration Script', () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
// Reset the project's promptGroupIds array
testProject.promptGroupIds = [];
await testProject.save();
await mongoose.connection.db
.collection('projects')
.updateOne({ name: 'instance' }, { $set: { promptGroupIds: [] } });
});
it('should categorize promptGroups correctly in dry run', async () => {
@ -118,8 +115,9 @@ describe('PromptGroup Migration Script', () => {
});
// Add global group to project's promptGroupIds array
testProject.promptGroupIds = [globalPromptGroup._id];
await testProject.save();
await mongoose.connection.db
.collection('projects')
.updateOne({ name: 'instance' }, { $set: { promptGroupIds: [globalPromptGroup._id] } });
const result = await migrateToPromptGroupPermissions({ dryRun: true });
@ -146,8 +144,9 @@ describe('PromptGroup Migration Script', () => {
});
// Add global group to project's promptGroupIds array
testProject.promptGroupIds = [globalPromptGroup._id];
await testProject.save();
await mongoose.connection.db
.collection('projects')
.updateOne({ name: 'instance' }, { $set: { promptGroupIds: [globalPromptGroup._id] } });
const result = await migrateToPromptGroupPermissions({ dryRun: false });

View file

@ -173,9 +173,6 @@ 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: ResourceType.AGENT,
@ -199,9 +196,6 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
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

View file

@ -14,10 +14,6 @@ jest.mock('~/server/services/Config', () => ({
}),
}));
jest.mock('~/models/Project', () => ({
getProjectByName: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
@ -174,7 +170,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Unauthorized fields that should be stripped
author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
authorName: 'Hacker', // Should be stripped
isCollaborative: true, // Should be stripped on creation
versions: [], // Should be stripped
_id: new mongoose.Types.ObjectId(), // Should be stripped
id: 'custom_agent_id', // Should be overridden
@ -193,7 +188,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Verify unauthorized fields were not set
expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
expect(createdAgent.authorName).toBeUndefined();
expect(createdAgent.isCollaborative).toBeFalsy();
expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
@ -444,7 +438,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
model: 'gpt-3.5-turbo',
author: existingAgentAuthorId,
description: 'Original description',
isCollaborative: false,
versions: [
{
name: 'Original Agent',
@ -466,7 +459,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
name: 'Updated Agent',
description: 'Updated description',
model: 'gpt-4',
isCollaborative: true, // This IS allowed in updates
};
await updateAgentHandler(mockReq, mockRes);
@ -479,13 +471,11 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.name).toBe('Updated Agent');
expect(updatedAgent.description).toBe('Updated description');
expect(updatedAgent.model).toBe('gpt-4');
expect(updatedAgent.isCollaborative).toBe(true);
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Updated Agent');
expect(agentInDb.isCollaborative).toBe(true);
});
test('should reject update with unauthorized fields (mass assignment protection)', async () => {
@ -540,25 +530,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.name).toBe('Admin Update');
});
test('should handle projectIds updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
const projectId1 = new mongoose.Types.ObjectId().toString();
const projectId2 = new mongoose.Types.ObjectId().toString();
mockReq.body = {
projectIds: [projectId1, projectId2],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent).toBeDefined();
});
test('should validate tool_resources in updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;

View file

@ -21,15 +21,6 @@ const checkAgentCreate = generateCheckAccess({
getRoleByName,
});
const checkGlobalAgentShare = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARE]: ['projectIds', 'removeProjectIds'],
},
getRoleByName,
});
router.use(requireJwtAuth);
/**
@ -99,7 +90,7 @@ router.get(
*/
router.patch(
'/:id',
checkGlobalAgentShare,
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
@ -148,7 +139,7 @@ router.delete(
*/
router.post(
'/:id/revert',
checkGlobalAgentShare,
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',

View file

@ -1,10 +1,9 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
const { getProjectByName } = require('~/models/Project');
const { getLogStores } = require('~/cache');
const router = express.Router();
@ -37,8 +36,6 @@ router.get('/', async function (req, res) {
return today.getMonth() === 1 && today.getDate() === 11;
};
const instanceProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
const ldap = getLdapConfig();
try {
@ -101,7 +98,6 @@ router.get('/', async function (req, res) {
sharedLinksEnabled,
publicSharedLinksEnabled,
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
instanceProjectId: instanceProject._id.toString(),
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
sharePointFilePickerEnabled,

View file

@ -56,15 +56,6 @@ const checkPromptCreate = generateCheckAccess({
getRoleByName,
});
const checkGlobalPromptShare = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARE]: ['projectIds', 'removeProjectIds'],
},
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkPromptAccess);
@ -364,7 +355,7 @@ const patchPromptGroup = async (req, res) => {
router.patch(
'/groups/:groupId',
checkGlobalPromptShare,
checkPromptCreate,
canAccessPromptGroupResource({
requiredPermission: PermissionBits.EDIT,
}),

View file

@ -6,7 +6,6 @@ const {
checkAgentPermissionsMigration,
checkPromptPermissionsMigration,
} = require('@librechat/api');
const { getProjectByName } = require('~/models/Project');
const { Agent, PromptGroup } = require('~/db/models');
const { findRoleByIdentifier } = require('~/models');
@ -20,7 +19,6 @@ async function checkMigrations() {
mongoose,
methods: {
findRoleByIdentifier,
getProjectByName,
},
AgentModel: Agent,
});
@ -33,7 +31,6 @@ async function checkMigrations() {
mongoose,
methods: {
findRoleByIdentifier,
getProjectByName,
},
PromptGroupModel: PromptGroup,
});

View file

@ -16,22 +16,13 @@ import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard';
import { detectVariables } from '~/utils';
function ChatGroupItem({
group,
instanceProjectId,
}: {
group: TPromptGroup;
instanceProjectId?: string;
}) {
function ChatGroupItem({ group }: { group: TPromptGroup }) {
const localize = useLocalize();
const { submitPrompt } = useSubmitMessage();
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const groupIsGlobal = useMemo(
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const groupIsGlobal = useMemo(() => group.isPublic === true, [group.isPublic]);
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');

View file

@ -19,10 +19,9 @@ import { cn } from '~/utils';
interface DashGroupItemProps {
group: TPromptGroup;
instanceProjectId?: string;
}
function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps) {
function DashGroupItemComponent({ group }: DashGroupItemProps) {
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
@ -35,10 +34,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
const canEdit = hasPermission(PermissionBits.EDIT);
const canDelete = hasPermission(PermissionBits.DELETE);
const isGlobalGroup = useMemo(
() => instanceProjectId && group.projectIds?.includes(instanceProjectId),
[group.projectIds, instanceProjectId],
);
const isPublicGroup = useMemo(() => group.isPublic === true, [group.isPublic]);
const updateGroup = useUpdatePromptGroup({
onMutate: () => {
@ -115,7 +111,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
</div>
<div className="flex h-full items-center gap-2">
{isGlobalGroup && (
{isPublicGroup && (
<EarthIcon
className="icon-md text-green-500"
aria-label={localize('com_ui_global_group')}

View file

@ -2,10 +2,9 @@ import { FileText, Plus } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import { cn } from '~/utils';
@ -19,8 +18,6 @@ export default function List({
isLoading: boolean;
}) {
const localize = useLocalize();
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@ -73,17 +70,9 @@ export default function List({
)}
{groups.map((group) => {
if (isChatRoute) {
return (
<ChatGroupItem
key={group._id}
group={group}
instanceProjectId={instanceProjectId}
/>
);
return <ChatGroupItem key={group._id} group={group} />;
}
return (
<DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />
);
return <DashGroupItem key={group._id} group={group} />;
})}
</div>
</div>

View file

@ -2,8 +2,8 @@
* @jest-environment jsdom
*/
import * as React from 'react';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent } from 'librechat-data-provider';
@ -255,7 +255,6 @@ const mockAgentQuery = (
data: {
id: 'agent-123',
author: 'user-123',
isCollaborative: false,
...agent,
} as Agent,
isInitialLoading: false,

View file

@ -26,8 +26,6 @@ mockUseWatch.mockImplementation(({ name }) => {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
@ -237,8 +235,6 @@ describe('AgentFooter', () => {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
@ -376,8 +372,6 @@ describe('AgentFooter', () => {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'different-user', // Different author
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
@ -403,8 +397,6 @@ describe('AgentFooter', () => {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123', // Same as current user
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {

View file

@ -46,12 +46,7 @@ export const useUpdatePromptGroup = (
]);
const previousListData = groupListData ? structuredClone(groupListData) : undefined;
let update = variables.payload;
if (update.removeProjectIds && group?.projectIds) {
update = structuredClone(update);
update.projectIds = group.projectIds.filter((id) => !update.removeProjectIds?.includes(id));
delete update.removeProjectIds;
}
const update = variables.payload;
if (groupListData) {
const newData = updateGroupFields(

View file

@ -2,16 +2,24 @@ const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { ensureRequiredCollectionsExist } = require('@librechat/api');
const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { grantPermission } = require('~/server/services/PermissionService');
const { getProjectByName } = require('~/models/Project');
const { findRoleByIdentifier } = require('~/models');
const { Agent, AclEntry } = require('~/db/models');
const GLOBAL_PROJECT_NAME = 'instance';
/** Queries the raw `projects` collection (which may still exist in the DB even though the model is removed) */
async function getGlobalProjectAgentIds(db) {
const project = await db
.collection('projects')
.findOne({ name: GLOBAL_PROJECT_NAME }, { projection: { agentIds: 1 } });
return new Set(project?.agentIds || []);
}
async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100 } = {}) {
await connect();
@ -24,7 +32,6 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
await ensureRequiredCollectionsExist(db);
}
// Verify required roles exist
const ownerRole = await findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
const editorRole = await findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
@ -33,9 +40,7 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
throw new Error('Required roles not found. Run role seeding first.');
}
// Get global project agent IDs (stores agent.id, not agent._id)
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalAgentIds = new Set(globalProject?.agentIds || []);
const globalAgentIds = db ? await getGlobalProjectAgentIds(db) : new Set();
logger.info(`Found ${globalAgentIds.size} agents in global project`);
@ -52,9 +57,9 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
.lean();
const categories = {
globalEditAccess: [], // Global project + collaborative -> Public EDIT
globalViewAccess: [], // Global project + not collaborative -> Public VIEW
privateAgents: [], // Not in global project -> Private (owner only)
globalEditAccess: [],
globalViewAccess: [],
privateAgents: [],
};
agentsToMigrate.forEach((agent) => {
@ -68,7 +73,6 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
} else {
categories.privateAgents.push(agent);
// Log warning if private agent claims to be collaborative
if (isCollab) {
logger.warn(
`Agent "${agent.name}" (${agent.id}) has isCollaborative=true but is not in global project`,
@ -130,7 +134,6 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
ownerGrants: 0,
};
// Process in batches
for (let i = 0; i < agentsToMigrate.length; i += batchSize) {
const batch = agentsToMigrate.slice(i, i + batchSize);
@ -143,7 +146,6 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
const isGlobal = globalAgentIds.has(agent.id);
const isCollab = agent.isCollaborative;
// Always grant owner permission to author
await grantPermission({
principalType: PrincipalType.USER,
principalId: agent.author,
@ -154,24 +156,20 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
});
results.ownerGrants++;
// Determine public permissions for global project agents only
let publicRoleId = null;
let description = 'Private';
if (isGlobal) {
if (isCollab) {
// Global project + collaborative = Public EDIT access
publicRoleId = AccessRoleIds.AGENT_EDITOR;
description = 'Global Edit';
results.publicEditGrants++;
} else {
// Global project + not collaborative = Public VIEW access
publicRoleId = AccessRoleIds.AGENT_VIEWER;
description = 'Global View';
results.publicViewGrants++;
}
// Grant public permission
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
@ -200,7 +198,6 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
}
}
// Brief pause between batches
await new Promise((resolve) => setTimeout(resolve, 100));
}

View file

@ -2,16 +2,24 @@ const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { ensureRequiredCollectionsExist } = require('@librechat/api');
const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { grantPermission } = require('~/server/services/PermissionService');
const { getProjectByName } = require('~/models/Project');
const { findRoleByIdentifier } = require('~/models');
const { PromptGroup, AclEntry } = require('~/db/models');
const GLOBAL_PROJECT_NAME = 'instance';
/** Queries the raw `projects` collection (which may still exist in the DB even though the model is removed) */
async function getGlobalProjectPromptGroupIds(db) {
const project = await db
.collection('projects')
.findOne({ name: GLOBAL_PROJECT_NAME }, { projection: { promptGroupIds: 1 } });
return new Set((project?.promptGroupIds || []).map((id) => id.toString()));
}
async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100 } = {}) {
await connect();
@ -24,7 +32,6 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
await ensureRequiredCollectionsExist(db);
}
// Verify required roles exist
const ownerRole = await findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
const editorRole = await findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_EDITOR);
@ -33,11 +40,7 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
throw new Error('Required promptGroup roles not found. Run role seeding first.');
}
// Get global project prompt group IDs
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
const globalPromptGroupIds = new Set(
(globalProject?.promptGroupIds || []).map((id) => id.toString()),
);
const globalPromptGroupIds = db ? await getGlobalProjectPromptGroupIds(db) : new Set();
logger.info(`Found ${globalPromptGroupIds.size} prompt groups in global project`);
@ -54,8 +57,8 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
.lean();
const categories = {
globalViewAccess: [], // PromptGroup in global project -> Public VIEW
privateGroups: [], // Not in global project -> Private (owner only)
globalViewAccess: [],
privateGroups: [],
};
promptGroupsToMigrate.forEach((group) => {
@ -115,7 +118,6 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
ownerGrants: 0,
};
// Process in batches
for (let i = 0; i < promptGroupsToMigrate.length; i += batchSize) {
const batch = promptGroupsToMigrate.slice(i, i + batchSize);
@ -127,7 +129,6 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
try {
const isGlobalGroup = globalPromptGroupIds.has(group._id.toString());
// Always grant owner permission to author
await grantPermission({
principalType: PrincipalType.USER,
principalId: group.author,
@ -138,7 +139,6 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
});
results.ownerGrants++;
// Grant public view permissions for promptGroups in global project
if (isGlobalGroup) {
await grantPermission({
principalType: PrincipalType.PUBLIC,
@ -170,7 +170,6 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
}
}
// Brief pause between batches
await new Promise((resolve) => setTimeout(resolve, 100));
}

View file

@ -1,20 +1,13 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import { AccessRoleIds, ResourceType, PrincipalType } from 'librechat-data-provider';
import { ensureRequiredCollectionsExist } from '../db/utils';
import type { AccessRoleMethods, IAgent } from '@librechat/data-schemas';
import type { Model, Mongoose } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
const GLOBAL_PROJECT_NAME = 'instance';
export interface MigrationCheckDbMethods {
findRoleByIdentifier: AccessRoleMethods['findRoleByIdentifier'];
getProjectByName: (
projectName: string,
fieldsToSelect?: string[] | null,
) => Promise<{
agentIds?: string[];
[key: string]: unknown;
} | null>;
}
export interface MigrationCheckParams {
@ -60,7 +53,6 @@ export async function checkAgentPermissionsMigration({
await ensureRequiredCollectionsExist(db);
}
// Verify required roles exist
const ownerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
const editorRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
@ -77,9 +69,13 @@ export async function checkAgentPermissionsMigration({
};
}
// Get global project agent IDs
const globalProject = await methods.getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalAgentIds = new Set(globalProject?.agentIds || []);
let globalAgentIds = new Set<string>();
if (db) {
const project = await db
.collection('projects')
.findOne({ name: GLOBAL_PROJECT_NAME }, { projection: { agentIds: 1 } });
globalAgentIds = new Set(project?.agentIds || []);
}
const AclEntry = mongoose.model('AclEntry');
const migratedAgentIds = await AclEntry.distinct('resourceId', {
@ -124,7 +120,6 @@ export async function checkAgentPermissionsMigration({
privateAgents: categories.privateAgents.length,
};
// Add details for debugging
if (agentsToMigrate.length > 0) {
result.details = {
globalEditAccess: categories.globalEditAccess.map((a) => ({
@ -152,7 +147,6 @@ export async function checkAgentPermissionsMigration({
return result;
} catch (error) {
logger.error('Failed to check agent permissions migration', error);
// Return zero counts on error to avoid blocking startup
return {
totalToMigrate: 0,
globalEditAccess: 0,
@ -170,7 +164,6 @@ export function logAgentMigrationWarning(result: MigrationCheckResult): void {
return;
}
// Create a visible warning box
const border = '='.repeat(80);
const warning = [
'',
@ -201,10 +194,8 @@ export function logAgentMigrationWarning(result: MigrationCheckResult): void {
'',
];
// Use console methods directly for visibility
console.log('\n' + warning.join('\n') + '\n');
// Also log with logger for consistency
logger.warn('Agent permissions migration required', {
totalToMigrate: result.totalToMigrate,
globalEditAccess: result.globalEditAccess,

View file

@ -94,9 +94,6 @@ export const agentUpdateSchema = agentBaseSchema.extend({
avatar: z.union([agentAvatarSchema, z.null()]).optional(),
provider: z.string().optional(),
model: z.string().nullable().optional(),
projectIds: z.array(z.string()).optional(),
removeProjectIds: z.array(z.string()).optional(),
isCollaborative: z.boolean().optional(),
});
interface ValidateAgentModelParams {

View file

@ -216,17 +216,12 @@ describe('access middleware', () => {
defaultParams.getRoleByName.mockResolvedValue(mockRole);
const checkObject = {
projectIds: ['project1'],
removeProjectIds: ['project2'],
};
const checkObject = {};
const result = await checkAccess({
...defaultParams,
permissions: [Permissions.USE, Permissions.SHARE],
bodyProps: {
[Permissions.SHARE]: ['projectIds', 'removeProjectIds'],
} as Record<Permissions, string[]>,
bodyProps: {} as Record<Permissions, string[]>,
checkObject,
});
expect(result).toBe(true);
@ -244,17 +239,12 @@ describe('access middleware', () => {
defaultParams.getRoleByName.mockResolvedValue(mockRole);
const checkObject = {
projectIds: ['project1'],
// missing removeProjectIds
};
const checkObject = {};
const result = await checkAccess({
...defaultParams,
permissions: [Permissions.SHARE],
bodyProps: {
[Permissions.SHARE]: ['projectIds', 'removeProjectIds'],
} as Record<Permissions, string[]>,
bodyProps: {} as Record<Permissions, string[]>,
checkObject,
});
expect(result).toBe(false);
@ -343,17 +333,12 @@ describe('access middleware', () => {
} as unknown as IRole;
mockGetRoleByName.mockResolvedValue(mockRole);
mockReq.body = {
projectIds: ['project1'],
removeProjectIds: ['project2'],
};
mockReq.body = {};
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE],
bodyProps: {
[Permissions.SHARE]: ['projectIds', 'removeProjectIds'],
} as Record<Permissions, string[]>,
bodyProps: {} as Record<Permissions, string[]>,
getRoleByName: mockGetRoleByName,
});

View file

@ -1,20 +1,13 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import { AccessRoleIds, ResourceType, PrincipalType } from 'librechat-data-provider';
import { ensureRequiredCollectionsExist } from '../db/utils';
import type { AccessRoleMethods, IPromptGroupDocument } from '@librechat/data-schemas';
import type { Model, Mongoose } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
const GLOBAL_PROJECT_NAME = 'instance';
export interface PromptMigrationCheckDbMethods {
findRoleByIdentifier: AccessRoleMethods['findRoleByIdentifier'];
getProjectByName: (
projectName: string,
fieldsToSelect?: string[] | null,
) => Promise<{
promptGroupIds?: string[];
[key: string]: unknown;
} | null>;
}
export interface PromptMigrationCheckParams {
@ -53,13 +46,11 @@ export async function checkPromptPermissionsMigration({
logger.debug('Checking if prompt permissions migration is needed');
try {
/** Native MongoDB database instance */
const db = mongoose.connection.db;
if (db) {
await ensureRequiredCollectionsExist(db);
}
// Verify required roles exist
const ownerRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
const editorRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_EDITOR);
@ -75,11 +66,15 @@ export async function checkPromptPermissionsMigration({
};
}
/** Global project prompt group IDs */
const globalProject = await methods.getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
const globalPromptGroupIds = new Set(
(globalProject?.promptGroupIds || []).map((id) => id.toString()),
);
let globalPromptGroupIds = new Set<string>();
if (db) {
const project = await db
.collection('projects')
.findOne({ name: GLOBAL_PROJECT_NAME }, { projection: { promptGroupIds: 1 } });
globalPromptGroupIds = new Set(
(project?.promptGroupIds || []).map((id: { toString(): string }) => id.toString()),
);
}
const AclEntry = mongoose.model('AclEntry');
const migratedGroupIds = await AclEntry.distinct('resourceId', {
@ -118,7 +113,6 @@ export async function checkPromptPermissionsMigration({
privateGroups: categories.privateGroups.length,
};
// Add details for debugging
if (promptGroupsToMigrate.length > 0) {
result.details = {
globalViewAccess: categories.globalViewAccess.map((g) => ({
@ -143,7 +137,6 @@ export async function checkPromptPermissionsMigration({
return result;
} catch (error) {
logger.error('Failed to check prompt permissions migration', error);
// Return zero counts on error to avoid blocking startup
return {
totalToMigrate: 0,
globalViewAccess: 0,
@ -160,7 +153,6 @@ export function logPromptMigrationWarning(result: PromptMigrationCheckResult): v
return;
}
// Create a visible warning box
const border = '='.repeat(80);
const warning = [
'',
@ -190,10 +182,8 @@ export function logPromptMigrationWarning(result: PromptMigrationCheckResult): v
'',
];
// Use console methods directly for visibility
console.log('\n' + warning.join('\n') + '\n');
// Also log with logger for consistency
logger.warn('Prompt permissions migration required', {
totalToMigrate: result.totalToMigrate,
globalViewAccess: result.globalViewAccess,

View file

@ -30,26 +30,6 @@ describe('updatePromptGroupSchema', () => {
}
});
it('should accept valid projectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
projectIds: ['proj1', 'proj2'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.projectIds).toEqual(['proj1', 'proj2']);
}
});
it('should accept valid removeProjectIds array', () => {
const result = updatePromptGroupSchema.safeParse({
removeProjectIds: ['proj1'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.removeProjectIds).toEqual(['proj1']);
}
});
it('should accept valid command field', () => {
const result = updatePromptGroupSchema.safeParse({ command: 'my-command-123' });
expect(result.success).toBe(true);

View file

@ -14,10 +14,6 @@ export const updatePromptGroupSchema = z
oneliner: z.string().max(500).optional(),
/** Category for organizing prompt groups */
category: z.string().max(100).optional(),
/** Project IDs to add for sharing */
projectIds: z.array(z.string()).optional(),
/** Project IDs to remove from sharing */
removeProjectIds: z.array(z.string()).optional(),
/** Command shortcut for the prompt group */
command: z
.string()

View file

@ -781,7 +781,6 @@ export type TStartupConfig = {
sharedLinksEnabled: boolean;
publicSharedLinksEnabled: boolean;
analyticsGtmId?: string;
instanceProjectId: string;
bundlerURL?: string;
staticBundlerURL?: string;
sharePointFilePickerEnabled?: boolean;
@ -1748,8 +1747,6 @@ export enum Constants {
SAVED_TAG = 'Saved',
/** Max number of Conversation starters for Agents/Assistants */
MAX_CONVO_STARTERS = 4,
/** Global/instance Project Name */
GLOBAL_PROJECT_NAME = 'instance',
/** Delimiter for MCP tools */
mcp_delimiter = '_mcp_',
/** Prefix for MCP plugins */

View file

@ -241,11 +241,8 @@ export const defaultAgentFormValues = {
tools: [],
tool_options: {},
provider: {},
projectIds: [],
edges: [],
artifacts: '',
/** @deprecated Use ACL permissions instead */
isCollaborative: false,
recursion_limit: undefined,
[Tools.execute_code]: false,
[Tools.file_search]: false,

View file

@ -539,7 +539,6 @@ export type TPromptGroup = {
command?: string;
oneliner?: string;
category?: string;
projectIds?: string[];
productionId?: string | null;
productionPrompt?: Pick<TPrompt, 'prompt'> | null;
author: string;
@ -592,9 +591,7 @@ export type TCreatePromptResponse = {
group?: TPromptGroup;
};
export type TUpdatePromptGroupPayload = Partial<TPromptGroup> & {
removeProjectIds?: string[];
};
export type TUpdatePromptGroupPayload = Partial<TPromptGroup>;
export type TUpdatePromptGroupVariables = {
id: string;

View file

@ -252,15 +252,12 @@ export type Agent = {
instructions?: string | null;
additional_instructions?: string | null;
tools?: string[];
projectIds?: string[];
tool_kwargs?: Record<string, unknown>;
metadata?: Record<string, unknown>;
provider: AgentProvider;
model: string | null;
model_parameters: AgentModelParameters;
conversation_starters?: string[];
/** @deprecated Use ACL permissions instead */
isCollaborative?: boolean;
tool_resources?: AgentToolResources;
/** @deprecated Use edges instead */
agent_ids?: string[];
@ -313,9 +310,6 @@ export type AgentUpdateParams = {
provider?: AgentProvider;
model?: string | null;
model_parameters?: AgentModelParameters;
projectIds?: string[];
removeProjectIds?: string[];
isCollaborative?: boolean;
} & Pick<
Agent,
| 'agent_ids'

View file

@ -24,7 +24,6 @@ const agentSchema = new Schema({
id: { type: String, required: true },
name: { type: String, required: true },
author: { type: String },
isCollaborative: { type: Boolean, default: false },
});
const promptGroupSchema = new Schema({
@ -107,7 +106,7 @@ describeIfFerretDB('Migration anti-join → $nin - FerretDB compatibility', () =
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id id name author isCollaborative')
.select('_id id name author')
.lean();
expect(toMigrate).toHaveLength(2);
@ -197,7 +196,6 @@ describeIfFerretDB('Migration anti-join → $nin - FerretDB compatibility', () =
id: 'proj_agent',
name: 'Field Test',
author: 'user1',
isCollaborative: true,
});
const migratedIds = await AclEntry.distinct('resourceId', {
@ -209,7 +207,7 @@ describeIfFerretDB('Migration anti-join → $nin - FerretDB compatibility', () =
_id: { $nin: migratedIds },
author: { $exists: true, $ne: null },
})
.select('_id id name author isCollaborative')
.select('_id id name author')
.lean();
expect(toMigrate).toHaveLength(1);
@ -218,7 +216,6 @@ describeIfFerretDB('Migration anti-join → $nin - FerretDB compatibility', () =
expect(agent).toHaveProperty('id', 'proj_agent');
expect(agent).toHaveProperty('name', 'Field Test');
expect(agent).toHaveProperty('author', 'user1');
expect(agent).toHaveProperty('isCollaborative', true);
});
});

View file

@ -27,7 +27,6 @@ const promptGroupSchema = new Schema(
author: { type: Schema.Types.ObjectId, required: true, index: true },
authorName: { type: String, required: true },
command: { type: String },
projectIds: { type: [Schema.Types.ObjectId], default: [] },
},
{ timestamps: true },
);
@ -51,7 +50,6 @@ type PromptGroupDoc = mongoose.Document & {
oneliner: string;
numberOfGenerations: number;
command?: string;
projectIds: Types.ObjectId[];
createdAt: Date;
updatedAt: Date;
};
@ -226,7 +224,7 @@ describeIfFerretDB('Prompt $lookup replacement - FerretDB compatibility', () =>
.skip(skip)
.limit(limit)
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
)
.lean(),
PromptGroup.countDocuments(query),
@ -273,7 +271,7 @@ describeIfFerretDB('Prompt $lookup replacement - FerretDB compatibility', () =>
.sort({ updatedAt: -1, _id: 1 })
.limit(normalizedLimit + 1)
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
)
.lean();
@ -303,7 +301,7 @@ describeIfFerretDB('Prompt $lookup replacement - FerretDB compatibility', () =>
const groups = await PromptGroup.find({ _id: { $in: accessibleIds } })
.sort({ updatedAt: -1, _id: 1 })
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
)
.lean();
@ -326,7 +324,7 @@ describeIfFerretDB('Prompt $lookup replacement - FerretDB compatibility', () =>
const groups = await PromptGroup.find({})
.select(
'name numberOfGenerations oneliner category projectIds productionId author authorName createdAt updatedAt',
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
)
.lean();
const result = await attachProductionPrompts(
@ -339,7 +337,6 @@ describeIfFerretDB('Prompt $lookup replacement - FerretDB compatibility', () =>
expect(item.numberOfGenerations).toBe(5);
expect(item.oneliner).toBe('A test prompt');
expect(item.category).toBe('testing');
expect(item.projectIds).toEqual([]);
expect(item.productionId).toBeDefined();
expect(item.author).toBeDefined();
expect(item.authorName).toBe('Test User');

View file

@ -37,7 +37,6 @@ const projectSchema = new Schema({
const agentSchema = new Schema({
name: { type: String, required: true },
projectIds: { type: [String], default: [] },
tool_resources: { type: Schema.Types.Mixed, default: {} },
});
@ -197,23 +196,6 @@ describeIfFerretDB('$pullAll FerretDB compatibility', () => {
expect(doc.agentIds).toEqual(['a2', 'a4']);
});
it('should remove projectIds from an agent', async () => {
await Agent.create({
name: 'Test Agent',
projectIds: ['p1', 'p2', 'p3'],
});
await Agent.findOneAndUpdate(
{ name: 'Test Agent' },
{ $pullAll: { projectIds: ['p1', 'p3'] } },
{ new: true },
);
const updated = await Agent.findOne({ name: 'Test Agent' }).lean();
const doc = updated as Record<string, unknown>;
expect(doc.projectIds).toEqual(['p2']);
});
it('should handle removing from nested dynamic paths (tool_resources)', async () => {
await Agent.create({
name: 'Resource Agent',

View file

@ -13,7 +13,6 @@ import { createActionModel } from './action';
import { createAssistantModel } from './assistant';
import { createFileModel } from './file';
import { createBannerModel } from './banner';
import { createProjectModel } from './project';
import { createKeyModel } from './key';
import { createPluginAuthModel } from './pluginAuth';
import { createTransactionModel } from './transaction';
@ -48,7 +47,6 @@ export function createModels(mongoose: typeof import('mongoose')) {
Assistant: createAssistantModel(mongoose),
File: createFileModel(mongoose),
Banner: createBannerModel(mongoose),
Project: createProjectModel(mongoose),
Key: createKeyModel(mongoose),
PluginAuth: createPluginAuthModel(mongoose),
Transaction: createTransactionModel(mongoose),

View file

@ -1,8 +0,0 @@
import projectSchema, { IMongoProject } from '~/schema/project';
/**
* Creates or returns the Project model using the provided mongoose instance and schema
*/
export function createProjectModel(mongoose: typeof import('mongoose')) {
return mongoose.models.Project || mongoose.model<IMongoProject>('Project', projectSchema);
}

View file

@ -76,10 +76,6 @@ const agentSchema = new Schema<IAgent>(
type: [{ type: Schema.Types.Mixed }],
default: [],
},
isCollaborative: {
type: Boolean,
default: undefined,
},
conversation_starters: {
type: [String],
default: [],
@ -88,11 +84,6 @@ const agentSchema = new Schema<IAgent>(
type: Schema.Types.Mixed,
default: {},
},
projectIds: {
type: [Schema.Types.ObjectId],
ref: 'Project',
index: true,
},
versions: {
type: [Schema.Types.Mixed],
default: [],

View file

@ -13,7 +13,6 @@ export { default as keySchema } from './key';
export { default as messageSchema } from './message';
export { default as pluginAuthSchema } from './pluginAuth';
export { default as presetSchema } from './preset';
export { default as projectSchema } from './project';
export { default as promptSchema } from './prompt';
export { default as promptGroupSchema } from './promptGroup';
export { default as roleSchema } from './role';

View file

@ -1,34 +0,0 @@
import { Schema, Document, Types } from 'mongoose';
export interface IMongoProject extends Document {
name: string;
promptGroupIds: Types.ObjectId[];
agentIds: string[];
createdAt?: Date;
updatedAt?: Date;
}
const projectSchema = new Schema<IMongoProject>(
{
name: {
type: String,
required: true,
index: true,
},
promptGroupIds: {
type: [Schema.Types.ObjectId],
ref: 'PromptGroup',
default: [],
},
agentIds: {
type: [String],
ref: 'Agent',
default: [],
},
},
{
timestamps: true,
},
);
export default projectSchema;

View file

@ -22,12 +22,6 @@ const promptGroupSchema = new Schema<IPromptGroupDocument>(
default: '',
index: true,
},
projectIds: {
type: [Schema.Types.ObjectId],
ref: 'Project',
index: true,
default: [],
},
productionId: {
type: Schema.Types.ObjectId,
ref: 'Prompt',

View file

@ -31,11 +31,8 @@ export interface IAgent extends Omit<Document, 'model'> {
/** @deprecated Use edges instead */
agent_ids?: string[];
edges?: GraphEdge[];
/** @deprecated Use ACL permissions instead */
isCollaborative?: boolean;
conversation_starters?: string[];
tool_resources?: unknown;
projectIds?: Types.ObjectId[];
versions?: Omit<IAgent, 'versions'>[];
category: string;
support_contact?: ISupportContact;

View file

@ -14,7 +14,6 @@ export interface IPromptGroup {
numberOfGenerations: number;
oneliner: string;
category: string;
projectIds: Types.ObjectId[];
productionId: Types.ObjectId;
author: Types.ObjectId;
authorName: string;