🔎 feat: Add Prompt and Agent Permissions Migration Checks (#9063)

* chore: fix mock typing in packages/api tests

* chore: improve imports, type handling and method signatures for MCPServersRegistry

* chore: use enum in migration scripts

* chore: ParsedServerConfig type to enhance server configuration handling

* feat: Implement agent permissions migration check and logging

* feat: Integrate migration checks into server initialization process

* feat: Add prompt permissions migration check and logging to server initialization

* chore: move prompt formatting functions to dedicated prompts dir
This commit is contained in:
Danny Avila 2025-08-14 17:20:00 -04:00 committed by GitHub
parent e8ddd279fd
commit e4e25aaf2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 636 additions and 96 deletions

View file

@ -1,5 +1,6 @@
export * from './config';
export * from './memory';
export * from './migration';
export * from './resources';
export * from './run';
export * from './validation';

View file

@ -0,0 +1,236 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import type { AccessRoleMethods, IAgent } from '@librechat/data-schemas';
import type { Model } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
export interface MigrationCheckDbMethods {
findRoleByIdentifier: AccessRoleMethods['findRoleByIdentifier'];
getProjectByName: (
projectName: string,
fieldsToSelect?: string[] | null,
) => Promise<{
agentIds?: string[];
[key: string]: unknown;
} | null>;
}
export interface MigrationCheckParams {
db: MigrationCheckDbMethods;
AgentModel: Model<IAgent>;
}
interface AgentMigrationData {
_id: string;
id: string;
name: string;
author: string;
isCollaborative: boolean;
}
export interface MigrationCheckResult {
totalToMigrate: number;
globalEditAccess: number;
globalViewAccess: number;
privateAgents: number;
details?: {
globalEditAccess: Array<{ name: string; id: string }>;
globalViewAccess: Array<{ name: string; id: string }>;
privateAgents: Array<{ name: string; id: string }>;
};
}
/**
* Check if agents need to be migrated to the new permission system
* This performs a dry-run check similar to the migration script
*/
export async function checkAgentPermissionsMigration({
db,
AgentModel,
}: MigrationCheckParams): Promise<MigrationCheckResult> {
logger.debug('Checking if agent permissions migration is needed');
try {
// Verify required roles exist
const ownerRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
const editorRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
if (!ownerRole || !viewerRole || !editorRole) {
logger.warn(
'Required agent roles not found. Permission system may not be fully initialized.',
);
return {
totalToMigrate: 0,
globalEditAccess: 0,
globalViewAccess: 0,
privateAgents: 0,
};
}
// Get global project agent IDs
const globalProject = await db.getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalAgentIds = new Set(globalProject?.agentIds || []);
// Find agents without ACL entries (no batching for efficiency on startup)
const agentsToMigrate: AgentMigrationData[] = await AgentModel.aggregate([
{
$lookup: {
from: 'aclentries',
localField: '_id',
foreignField: 'resourceId',
as: 'aclEntries',
},
},
{
$addFields: {
userAclEntries: {
$filter: {
input: '$aclEntries',
as: 'aclEntry',
cond: {
$and: [
{ $eq: ['$$aclEntry.resourceType', ResourceType.AGENT] },
{ $eq: ['$$aclEntry.principalType', PrincipalType.USER] },
],
},
},
},
},
},
{
$match: {
author: { $exists: true, $ne: null },
userAclEntries: { $size: 0 },
},
},
{
$project: {
_id: 1,
id: 1,
name: 1,
author: 1,
isCollaborative: 1,
},
},
]);
const categories: {
globalEditAccess: AgentMigrationData[];
globalViewAccess: AgentMigrationData[];
privateAgents: AgentMigrationData[];
} = {
globalEditAccess: [],
globalViewAccess: [],
privateAgents: [],
};
agentsToMigrate.forEach((agent) => {
const isGlobal = globalAgentIds.has(agent.id);
const isCollab = agent.isCollaborative;
if (isGlobal && isCollab) {
categories.globalEditAccess.push(agent);
} else if (isGlobal && !isCollab) {
categories.globalViewAccess.push(agent);
} else {
categories.privateAgents.push(agent);
}
});
const result: MigrationCheckResult = {
totalToMigrate: agentsToMigrate.length,
globalEditAccess: categories.globalEditAccess.length,
globalViewAccess: categories.globalViewAccess.length,
privateAgents: categories.privateAgents.length,
};
// Add details for debugging
if (agentsToMigrate.length > 0) {
result.details = {
globalEditAccess: categories.globalEditAccess.map((a) => ({
name: a.name,
id: a.id,
})),
globalViewAccess: categories.globalViewAccess.map((a) => ({
name: a.name,
id: a.id,
})),
privateAgents: categories.privateAgents.map((a) => ({
name: a.name,
id: a.id,
})),
};
}
logger.debug('Agent migration check completed', {
totalToMigrate: result.totalToMigrate,
globalEditAccess: result.globalEditAccess,
globalViewAccess: result.globalViewAccess,
privateAgents: result.privateAgents,
});
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,
globalViewAccess: 0,
privateAgents: 0,
};
}
}
/**
* Log migration warning to console if agents need migration
*/
export function logAgentMigrationWarning(result: MigrationCheckResult): void {
if (result.totalToMigrate === 0) {
return;
}
// Create a visible warning box
const border = '='.repeat(80);
const warning = [
'',
border,
' IMPORTANT: AGENT PERMISSIONS MIGRATION REQUIRED',
border,
'',
` Total agents to migrate: ${result.totalToMigrate}`,
` - Global Edit Access: ${result.globalEditAccess} agents`,
` - Global View Access: ${result.globalViewAccess} agents`,
` - Private Agents: ${result.privateAgents} agents`,
'',
' The new agent sharing system requires migrating existing agents.',
' Please run the following command to migrate your agents:',
'',
' npm run migrate:agent-permissions',
'',
' For a dry run (preview) of what will be migrated:',
'',
' npm run migrate:agent-permissions:dry-run',
'',
' This migration will:',
' 1. Grant owner permissions to agent authors',
' 2. Set appropriate public permissions based on global project status',
' 3. Preserve existing collaborative settings',
'',
border,
'',
];
// 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,
globalViewAccess: result.globalViewAccess,
privateAgents: result.privateAgents,
});
}

View file

@ -1,2 +1 @@
export * from './content';
export * from './prompts';

View file

@ -19,6 +19,8 @@ export * from './middleware';
export * from './memory';
/* Agents */
export * from './agents';
/* Prompts */
export * from './prompts';
/* Endpoints */
export * from './endpoints';
/* Files */

View file

@ -1,23 +1,15 @@
import { logger } from '@librechat/data-schemas';
import mapValues from 'lodash/mapValues';
import pickBy from 'lodash/pickBy';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import mapValues from 'lodash/mapValues';
import { logger } from '@librechat/data-schemas';
import type { MCPConnection } from '~/mcp/connection';
import type { JsonSchemaType } from '~/types';
import type * as t from '~/mcp/types';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { detectOAuthRequirement } from '~/mcp/oauth';
import { type MCPConnection } from './connection';
import { processMCPEnv } from '~/utils';
import { CONSTANTS } from '~/mcp/enum';
type ParsedServerConfig = t.MCPOptions & {
url?: string;
requiresOAuth?: boolean;
oauthMetadata?: Record<string, unknown> | null;
capabilities?: string;
tools?: string;
};
/**
* Manages MCP server configurations and metadata discovery.
* Fetches server capabilities, OAuth requirements, and tool definitions for registry.
@ -29,7 +21,7 @@ export class MCPServersRegistry {
private connections: ConnectionsRepository;
public readonly rawConfigs: t.MCPServers;
public readonly parsedConfigs: Record<string, ParsedServerConfig>;
public readonly parsedConfigs: Record<string, t.ParsedServerConfig>;
public oauthServers: Set<string> | null = null;
public serverInstructions: Record<string, string> | null = null;
@ -43,7 +35,7 @@ export class MCPServersRegistry {
}
/** Initializes all startup-enabled servers by gathering their metadata asynchronously */
public async initialize() {
public async initialize(): Promise<void> {
if (this.initialized) return;
this.initialized = true;
@ -59,8 +51,8 @@ export class MCPServersRegistry {
this.connections.disconnectAll();
}
// Fetches all metadata for a single server in parallel
private async gatherServerInfo(serverName: string) {
/** Fetches all metadata for a single server in parallel */
private async gatherServerInfo(serverName: string): Promise<void> {
try {
await this.fetchOAuthRequirement(serverName);
const config = this.parsedConfigs[serverName];
@ -82,8 +74,8 @@ export class MCPServersRegistry {
}
}
// Sets app-level server configs (startup enabled, non-OAuth servers)
private setAppServerConfigs() {
/** Sets app-level server configs (startup enabled, non-OAuth servers) */
private setAppServerConfigs(): void {
const appServers = Object.keys(
pickBy(
this.parsedConfigs,
@ -93,8 +85,8 @@ export class MCPServersRegistry {
this.appServerConfigs = pick(this.rawConfigs, appServers);
}
// Creates set of server names that require OAuth authentication
private setOAuthServers() {
/** Creates set of server names that require OAuth authentication */
private setOAuthServers(): Set<string> {
if (this.oauthServers) return this.oauthServers;
this.oauthServers = new Set(
Object.keys(pickBy(this.parsedConfigs, (config) => config.requiresOAuth)),
@ -102,16 +94,16 @@ export class MCPServersRegistry {
return this.oauthServers;
}
// Collects server instructions from all configured servers
private setServerInstructions() {
/** Collects server instructions from all configured servers */
private setServerInstructions(): void {
this.serverInstructions = mapValues(
pickBy(this.parsedConfigs, (config) => config.serverInstructions),
(config) => config.serverInstructions as string,
);
}
// Builds registry of all available tool functions from loaded connections
private async setAppToolFunctions() {
/** Builds registry of all available tool functions from loaded connections */
private async setAppToolFunctions(): Promise<void> {
const connections = (await this.connections.getLoaded()).entries();
const allToolFunctions: t.LCAvailableTools = {};
for (const [serverName, conn] of connections) {
@ -125,12 +117,12 @@ export class MCPServersRegistry {
this.toolFunctions = allToolFunctions;
}
// Converts server tools to LibreChat-compatible tool functions format
/** Converts server tools to LibreChat-compatible tool functions format */
private async getToolFunctions(
serverName: string,
conn: MCPConnection,
): Promise<t.LCAvailableTools> {
const { tools } = await conn.client.listTools();
const { tools }: t.MCPToolListResponse = await conn.client.listTools();
const toolFunctions: t.LCAvailableTools = {};
tools.forEach((tool) => {
@ -148,7 +140,7 @@ export class MCPServersRegistry {
return toolFunctions;
}
// Determines if server requires OAuth if not already specified in the config
/** Determines if server requires OAuth if not already specified in the config */
private async fetchOAuthRequirement(serverName: string): Promise<boolean> {
const config = this.parsedConfigs[serverName];
if (config.requiresOAuth != null) return config.requiresOAuth;
@ -161,8 +153,8 @@ export class MCPServersRegistry {
return config.requiresOAuth;
}
// Retrieves server instructions from MCP server if enabled in the config
private async fetchServerInstructions(serverName: string) {
/** Retrieves server instructions from MCP server if enabled in the config */
private async fetchServerInstructions(serverName: string): Promise<void> {
const config = this.parsedConfigs[serverName];
if (!config.serverInstructions) return;
if (typeof config.serverInstructions === 'string') return;
@ -174,8 +166,8 @@ export class MCPServersRegistry {
}
}
// Fetches server capabilities and available tools list
private async fetchServerCapabilities(serverName: string) {
/** Fetches server capabilities and available tools list */
private async fetchServerCapabilities(serverName: string): Promise<void> {
const config = this.parsedConfigs[serverName];
const conn = await this.connections.get(serverName);
const capabilities = conn.client.getServerCapabilities();
@ -187,7 +179,7 @@ export class MCPServersRegistry {
}
// Logs server configuration summary after initialization
private logUpdatedConfig(serverName: string) {
private logUpdatedConfig(serverName: string): void {
const prefix = this.prefix(serverName);
const config = this.parsedConfigs[serverName];
logger.info(`${prefix} -------------------------------------------------┐`);

View file

@ -1,12 +1,13 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '@librechat/data-schemas';
import { readFileSync } from 'fs';
import { load as yamlLoad } from 'js-yaml';
import { ConnectionsRepository } from '../ConnectionsRepository';
import { MCPServersRegistry } from '../MCPServersRegistry';
import { logger } from '@librechat/data-schemas';
import type { OAuthDetectionResult } from '~/mcp/oauth/detectOAuth';
import type * as t from '~/mcp/types';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { MCPServersRegistry } from '~/mcp/MCPServersRegistry';
import { detectOAuthRequirement } from '~/mcp/oauth';
import { MCPConnection } from '../connection';
import type * as t from '../types';
import { MCPConnection } from '~/mcp/connection';
// Mock external dependencies
jest.mock('../oauth/detectOAuth');
@ -37,7 +38,7 @@ const mockLogger = logger as jest.Mocked<typeof logger>;
describe('MCPServersRegistry - Initialize Function', () => {
let rawConfigs: t.MCPServers;
let expectedParsedConfigs: Record<string, any>;
let expectedParsedConfigs: Record<string, t.ParsedServerConfig>;
let mockConnectionsRepo: jest.Mocked<ConnectionsRepository>;
let mockConnections: Map<string, jest.Mocked<MCPConnection>>;
@ -49,7 +50,7 @@ describe('MCPServersRegistry - Initialize Function', () => {
rawConfigs = yamlLoad(readFileSync(rawConfigsPath, 'utf8')) as t.MCPServers;
expectedParsedConfigs = yamlLoad(readFileSync(parsedConfigsPath, 'utf8')) as Record<
string,
any
t.ParsedServerConfig
>;
// Setup mock connections
@ -57,12 +58,13 @@ describe('MCPServersRegistry - Initialize Function', () => {
const serverNames = Object.keys(rawConfigs);
serverNames.forEach((serverName) => {
const mockClient = {
listTools: jest.fn(),
getInstructions: jest.fn(),
getServerCapabilities: jest.fn(),
};
const mockConnection = {
client: {
listTools: jest.fn(),
getInstructions: jest.fn(),
getServerCapabilities: jest.fn(),
},
client: mockClient,
} as unknown as jest.Mocked<MCPConnection>;
// Setup mock responses based on expected configs
@ -75,30 +77,32 @@ describe('MCPServersRegistry - Initialize Function', () => {
name,
description: `Description for ${name}`,
inputSchema: {
type: 'object',
type: 'object' as const,
properties: {
input: { type: 'string' },
},
},
}));
mockConnection.client.listTools.mockResolvedValue({ tools });
(mockClient.listTools as jest.Mock).mockResolvedValue({ tools });
} else {
mockConnection.client.listTools.mockResolvedValue({ tools: [] });
(mockClient.listTools as jest.Mock).mockResolvedValue({ tools: [] });
}
// Mock getInstructions response
if (expectedConfig.serverInstructions) {
mockConnection.client.getInstructions.mockReturnValue(expectedConfig.serverInstructions);
(mockClient.getInstructions as jest.Mock).mockReturnValue(
expectedConfig.serverInstructions as string,
);
} else {
mockConnection.client.getInstructions.mockReturnValue(null);
(mockClient.getInstructions as jest.Mock).mockReturnValue(undefined);
}
// Mock getServerCapabilities response
if (expectedConfig.capabilities) {
const capabilities = JSON.parse(expectedConfig.capabilities);
mockConnection.client.getServerCapabilities.mockReturnValue(capabilities);
const capabilities = JSON.parse(expectedConfig.capabilities) as Record<string, unknown>;
(mockClient.getServerCapabilities as jest.Mock).mockReturnValue(capabilities);
} else {
mockConnection.client.getServerCapabilities.mockReturnValue(null);
(mockClient.getServerCapabilities as jest.Mock).mockReturnValue(undefined);
}
mockConnections.set(serverName, mockConnection);
@ -111,9 +115,13 @@ describe('MCPServersRegistry - Initialize Function', () => {
disconnectAll: jest.fn(),
} as unknown as jest.Mocked<ConnectionsRepository>;
mockConnectionsRepo.get.mockImplementation((serverName: string) =>
Promise.resolve(mockConnections.get(serverName)!),
);
mockConnectionsRepo.get.mockImplementation((serverName: string) => {
const connection = mockConnections.get(serverName);
if (!connection) {
throw new Error(`Connection not found for server: ${serverName}`);
}
return Promise.resolve(connection);
});
mockConnectionsRepo.getLoaded.mockResolvedValue(mockConnections);
@ -121,9 +129,10 @@ describe('MCPServersRegistry - Initialize Function', () => {
// Setup OAuth detection mock with deterministic results
mockDetectOAuthRequirement.mockImplementation((url: string) => {
const oauthResults: Record<string, any> = {
const oauthResults: Record<string, OAuthDetectionResult> = {
'https://api.github.com/mcp': {
requiresOAuth: true,
method: 'protected-resource-metadata',
metadata: {
authorization_url: 'https://github.com/login/oauth/authorize',
token_url: 'https://github.com/login/oauth/access_token',
@ -131,15 +140,19 @@ describe('MCPServersRegistry - Initialize Function', () => {
},
'https://api.disabled.com/mcp': {
requiresOAuth: false,
method: 'no-metadata-found',
metadata: null,
},
'https://api.public.com/mcp': {
requiresOAuth: false,
method: 'no-metadata-found',
metadata: null,
},
};
return Promise.resolve(oauthResults[url] || { requiresOAuth: false, metadata: null });
return Promise.resolve(
oauthResults[url] || { requiresOAuth: false, method: 'no-metadata-found', metadata: null },
);
});
// Clear all mocks

View file

@ -105,3 +105,11 @@ export type FormattedToolResponse = [
string | FormattedContent[],
{ content: FormattedContent[] } | undefined,
];
export type ParsedServerConfig = MCPOptions & {
url?: string;
requiresOAuth?: boolean;
oauthMetadata?: Record<string, unknown> | null;
capabilities?: string;
tools?: string;
};

View file

@ -15,6 +15,7 @@ jest.mock('@librechat/data-schemas', () => ({
describe('ErrorController', () => {
let mockReq: Request;
let mockRes: Response;
let mockNext: jest.Mock;
beforeEach(() => {
mockReq = {
@ -25,6 +26,7 @@ describe('ErrorController', () => {
send: jest.fn(),
} as unknown as Response;
(logger.error as jest.Mock).mockClear();
mockNext = jest.fn();
});
describe('ValidationError handling', () => {
@ -37,7 +39,7 @@ describe('ErrorController', () => {
},
} as ValidationError;
ErrorController(validationError, mockReq, mockRes);
ErrorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@ -57,7 +59,7 @@ describe('ErrorController', () => {
},
} as ValidationError;
ErrorController(validationError, mockReq, mockRes);
ErrorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@ -73,7 +75,7 @@ describe('ErrorController', () => {
errors: {},
} as ValidationError;
ErrorController(validationError, mockReq, mockRes);
ErrorController(validationError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith({
@ -94,7 +96,7 @@ describe('ErrorController', () => {
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
ErrorController(duplicateKeyError, mockReq, mockRes);
ErrorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
@ -116,7 +118,7 @@ describe('ErrorController', () => {
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
ErrorController(duplicateKeyError, mockReq, mockRes);
ErrorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
@ -138,7 +140,7 @@ describe('ErrorController', () => {
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
} as MongoServerError;
ErrorController(duplicateKeyError, mockReq, mockRes);
ErrorController(duplicateKeyError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(409);
expect(mockRes.send).toHaveBeenCalledWith({
@ -155,7 +157,7 @@ describe('ErrorController', () => {
body: 'Invalid JSON syntax',
} as CustomError;
ErrorController(syntaxError, mockReq, mockRes);
ErrorController(syntaxError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
@ -167,7 +169,7 @@ describe('ErrorController', () => {
body: { error: 'Unprocessable entity' },
} as CustomError;
ErrorController(customError, mockReq, mockRes);
ErrorController(customError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(422);
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
@ -178,7 +180,7 @@ describe('ErrorController', () => {
statusCode: 400,
} as CustomError;
ErrorController(partialError, mockReq, mockRes);
ErrorController(partialError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@ -189,7 +191,7 @@ describe('ErrorController', () => {
body: 'Some error message',
} as CustomError;
ErrorController(partialError, mockReq, mockRes);
ErrorController(partialError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@ -200,7 +202,7 @@ describe('ErrorController', () => {
it('should handle unknown errors', () => {
const unknownError = new Error('Some unknown error');
ErrorController(unknownError, mockReq, mockRes);
ErrorController(unknownError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@ -213,7 +215,7 @@ describe('ErrorController', () => {
message: 'Some MongoDB error',
} as MongoServerError;
ErrorController(mongoError, mockReq, mockRes);
ErrorController(mongoError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@ -223,7 +225,7 @@ describe('ErrorController', () => {
it('should handle generic errors', () => {
const genericError = new Error('Test error');
ErrorController(genericError, mockReq, mockRes);
ErrorController(genericError, mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
@ -254,7 +256,7 @@ describe('ErrorController', () => {
const testError = new Error('Test error');
ErrorController(testError, mockReq, freshMockRes);
ErrorController(testError, mockReq, freshMockRes, mockNext);
expect(freshMockRes.status).toHaveBeenCalledWith(500);
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');

View file

@ -0,0 +1,2 @@
export * from './format';
export * from './migration';

View file

@ -0,0 +1,223 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import type { AccessRoleMethods, IPromptGroupDocument } from '@librechat/data-schemas';
import type { Model } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
export interface PromptMigrationCheckDbMethods {
findRoleByIdentifier: AccessRoleMethods['findRoleByIdentifier'];
getProjectByName: (
projectName: string,
fieldsToSelect?: string[] | null,
) => Promise<{
promptGroupIds?: string[];
[key: string]: unknown;
} | null>;
}
export interface PromptMigrationCheckParams {
db: PromptMigrationCheckDbMethods;
PromptGroupModel: Model<IPromptGroupDocument>;
}
interface PromptGroupMigrationData {
_id: string;
name: string;
author: string;
authorName?: string;
category?: string;
}
export interface PromptMigrationCheckResult {
totalToMigrate: number;
globalViewAccess: number;
privateGroups: number;
details?: {
globalViewAccess: Array<{ name: string; _id: string; category: string }>;
privateGroups: Array<{ name: string; _id: string; category: string }>;
};
}
/**
* Check if prompt groups need to be migrated to the new permission system
* This performs a dry-run check similar to the migration script
*/
export async function checkPromptPermissionsMigration({
db,
PromptGroupModel,
}: PromptMigrationCheckParams): Promise<PromptMigrationCheckResult> {
logger.debug('Checking if prompt permissions migration is needed');
try {
// Verify required roles exist
const ownerRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
const editorRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_EDITOR);
if (!ownerRole || !viewerRole || !editorRole) {
logger.warn(
'Required promptGroup roles not found. Permission system may not be fully initialized.',
);
return {
totalToMigrate: 0,
globalViewAccess: 0,
privateGroups: 0,
};
}
// Get global project prompt group IDs
const globalProject = await db.getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
const globalPromptGroupIds = new Set(
(globalProject?.promptGroupIds || []).map((id) => id.toString()),
);
// Find promptGroups without ACL entries (no batching for efficiency on startup)
const promptGroupsToMigrate: PromptGroupMigrationData[] = await PromptGroupModel.aggregate([
{
$lookup: {
from: 'aclentries',
localField: '_id',
foreignField: 'resourceId',
as: 'aclEntries',
},
},
{
$addFields: {
promptGroupAclEntries: {
$filter: {
input: '$aclEntries',
as: 'aclEntry',
cond: {
$and: [
{ $eq: ['$$aclEntry.resourceType', ResourceType.PROMPTGROUP] },
{ $eq: ['$$aclEntry.principalType', PrincipalType.USER] },
],
},
},
},
},
},
{
$match: {
author: { $exists: true, $ne: null },
promptGroupAclEntries: { $size: 0 },
},
},
{
$project: {
_id: 1,
name: 1,
author: 1,
authorName: 1,
category: 1,
},
},
]);
const categories: {
globalViewAccess: PromptGroupMigrationData[];
privateGroups: PromptGroupMigrationData[];
} = {
globalViewAccess: [],
privateGroups: [],
};
promptGroupsToMigrate.forEach((group) => {
const isGlobalGroup = globalPromptGroupIds.has(group._id.toString());
if (isGlobalGroup) {
categories.globalViewAccess.push(group);
} else {
categories.privateGroups.push(group);
}
});
const result: PromptMigrationCheckResult = {
totalToMigrate: promptGroupsToMigrate.length,
globalViewAccess: categories.globalViewAccess.length,
privateGroups: categories.privateGroups.length,
};
// Add details for debugging
if (promptGroupsToMigrate.length > 0) {
result.details = {
globalViewAccess: categories.globalViewAccess.map((g) => ({
name: g.name,
_id: g._id.toString(),
category: g.category || 'uncategorized',
})),
privateGroups: categories.privateGroups.map((g) => ({
name: g.name,
_id: g._id.toString(),
category: g.category || 'uncategorized',
})),
};
}
logger.debug('Prompt migration check completed', {
totalToMigrate: result.totalToMigrate,
globalViewAccess: result.globalViewAccess,
privateGroups: result.privateGroups,
});
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,
privateGroups: 0,
};
}
}
/**
* Log migration warning to console if prompt groups need migration
*/
export function logPromptMigrationWarning(result: PromptMigrationCheckResult): void {
if (result.totalToMigrate === 0) {
return;
}
// Create a visible warning box
const border = '='.repeat(80);
const warning = [
'',
border,
' IMPORTANT: PROMPT PERMISSIONS MIGRATION REQUIRED',
border,
'',
` Total prompt groups to migrate: ${result.totalToMigrate}`,
` - Global View Access: ${result.globalViewAccess} prompt groups`,
` - Private Prompt Groups: ${result.privateGroups} prompt groups`,
'',
' The new prompt sharing system requires migrating existing prompt groups.',
' Please run the following command to migrate your prompts:',
'',
' npm run migrate:prompt-permissions',
'',
' For a dry run (preview) of what will be migrated:',
'',
' npm run migrate:prompt-permissions:dry-run',
'',
' This migration will:',
' 1. Grant owner permissions to prompt authors',
' 2. Set public view permissions for prompts in the global project',
' 3. Keep private prompts accessible only to their authors',
'',
border,
'',
];
// 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,
privateGroups: result.privateGroups,
});
}

View file

@ -1,18 +1,19 @@
import { createUserMethods, type UserMethods } from './user';
import { createSessionMethods, type SessionMethods } from './session';
import { createTokenMethods, type TokenMethods } from './token';
import { createRoleMethods, type RoleMethods } from './role';
import { createUserMethods, type UserMethods } from './user';
/* Memories */
import { createMemoryMethods, type MemoryMethods } from './memory';
/* Agent Categories */
import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory';
/* Plugin Auth */
import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
/* Permissions */
import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
import { createAclEntryMethods, type AclEntryMethods } from './aclEntry';
import { createGroupMethods, type GroupMethods } from './group';
import { createShareMethods, type ShareMethods } from './share';
import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
/**
* Creates all database methods for all collections
@ -34,16 +35,29 @@ export function createMethods(mongoose: typeof import('mongoose')) {
};
}
export type { MemoryMethods, ShareMethods, TokenMethods, PluginAuthMethods };
export type {
UserMethods,
SessionMethods,
TokenMethods,
RoleMethods,
MemoryMethods,
AgentCategoryMethods,
UserGroupMethods,
AclEntryMethods,
GroupMethods,
ShareMethods,
AccessRoleMethods,
PluginAuthMethods,
};
export type AllMethods = UserMethods &
SessionMethods &
TokenMethods &
RoleMethods &
MemoryMethods &
AgentCategoryMethods &
AccessRoleMethods &
UserGroupMethods &
AclEntryMethods &
GroupMethods &
ShareMethods &
AccessRoleMethods &
PluginAuthMethods;