diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index f03c2281a9..74ae27abf3 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1 +1,3 @@ export * from './config'; +export * from './interface'; +export * from './permissions'; diff --git a/packages/api/src/app/interface.ts b/packages/api/src/app/interface.ts new file mode 100644 index 0000000000..3a03d09434 --- /dev/null +++ b/packages/api/src/app/interface.ts @@ -0,0 +1,108 @@ +import { logger } from '@librechat/data-schemas'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; +import type { AppConfig } from '~/types/config'; +import { isMemoryEnabled } from '~/memory/config'; + +/** + * Loads the default interface object. + * @param params - The loaded custom configuration. + * @param params.config - The loaded custom configuration. + * @param params.configDefaults - The custom configuration default values. + * @returns default interface object. + */ +export async function loadDefaultInterface({ + config, + configDefaults, +}: { + config?: Partial; + configDefaults: TConfigDefaults; +}): Promise { + const { interface: interfaceConfig } = config ?? {}; + const { interface: defaults } = configDefaults; + const hasModelSpecs = (config?.modelSpecs?.list?.length ?? 0) > 0; + const includesAddedEndpoints = (config?.modelSpecs?.addedEndpoints?.length ?? 0) > 0; + + const memoryConfig = config?.memory; + const memoryEnabled = isMemoryEnabled(memoryConfig); + /** Only disable memories if memory config is present but disabled/invalid */ + const shouldDisableMemories = memoryConfig && !memoryEnabled; + + const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({ + // UI elements - use schema defaults + endpointsMenu: + interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu), + modelSelect: + interfaceConfig?.modelSelect ?? + (hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect), + parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters), + presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets), + sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel, + privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, + termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, + mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers, + customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, + + // Permissions - only include if explicitly configured + bookmarks: interfaceConfig?.bookmarks, + memories: shouldDisableMemories ? false : interfaceConfig?.memories, + prompts: interfaceConfig?.prompts, + multiConvo: interfaceConfig?.multiConvo, + agents: interfaceConfig?.agents, + temporaryChat: interfaceConfig?.temporaryChat, + runCode: interfaceConfig?.runCode, + webSearch: interfaceConfig?.webSearch, + fileSearch: interfaceConfig?.fileSearch, + fileCitations: interfaceConfig?.fileCitations, + peoplePicker: interfaceConfig?.peoplePicker, + marketplace: interfaceConfig?.marketplace, + }); + + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(config?.modelSpecs ?? {}), list: undefined }, + null, + 2, + )}`, + ); + }; + + // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. + if (config?.modelSpecs?.prioritize && loadedInterface.presets) { + logger.warn( + "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", + ); + if (i === 0) i++; + } + + // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. + if ( + config?.modelSpecs?.enforce && + (loadedInterface.endpointsMenu || + loadedInterface.modelSelect || + loadedInterface.presets || + loadedInterface.parameters) + ) { + logger.warn( + "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", + ); + if (i === 0) i++; + } + // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. + if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { + logger.warn( + "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", + ); + if (i === 0) i++; + } + + if (i > 0) { + logSettings(); + } + + return loadedInterface; +} diff --git a/api/server/services/start/interface.spec.js b/packages/api/src/app/permissions.spec.ts similarity index 81% rename from api/server/services/start/interface.spec.js rename to packages/api/src/app/permissions.spec.ts index 02710901c9..484f997edd 100644 --- a/api/server/services/start/interface.spec.js +++ b/packages/api/src/app/permissions.spec.ts @@ -1,27 +1,21 @@ -const { - SystemRoles, - Permissions, - PermissionTypes, - roleDefaults, -} = require('librechat-data-provider'); -const { updateAccessPermissions, getRoleByName } = require('~/models/Role'); -const { loadDefaultInterface } = require('./interface'); +import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider'; +import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '~/types/config'; +import { updateInterfacePermissions } from './permissions'; +import { loadDefaultInterface } from './interface'; -jest.mock('~/models/Role', () => ({ - updateAccessPermissions: jest.fn(), - getRoleByName: jest.fn(), -})); +const mockUpdateAccessPermissions = jest.fn(); +const mockGetRoleByName = jest.fn(); -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), +jest.mock('~/memory', () => ({ isMemoryEnabled: jest.fn((config) => config?.enable === true), })); -describe('loadDefaultInterface', () => { +describe('updateInterfacePermissions - permissions', () => { beforeEach(() => { jest.clearAllMocks(); // Mock getRoleByName to return null (no existing permissions) - getRoleByName.mockResolvedValue(null); + mockGetRoleByName.mockResolvedValue(null); }); it('should call updateAccessPermissions with the correct parameters when permission types are true', async () => { @@ -47,9 +41,15 @@ describe('loadDefaultInterface', () => { }, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -117,17 +117,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -157,9 +157,15 @@ describe('loadDefaultInterface', () => { }, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -227,17 +233,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -267,9 +273,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -337,17 +349,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -390,9 +402,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -460,17 +478,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -500,9 +518,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { @@ -570,17 +594,17 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Check USER role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, null, ); // Check ADMIN role call - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, null, @@ -589,7 +613,7 @@ describe('loadDefaultInterface', () => { it('should only update permissions that do not exist when no config provided', async () => { // Mock that some permissions already exist - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, @@ -618,9 +642,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should be called with all permissions EXCEPT prompts and agents (which already exist) const expectedPermissionsForUser = { @@ -669,8 +699,8 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, expect.objectContaining({ @@ -680,7 +710,7 @@ describe('loadDefaultInterface', () => { }, }), ); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, expect.objectContaining({ @@ -694,7 +724,7 @@ describe('loadDefaultInterface', () => { it('should override existing permissions when explicitly configured', async () => { // Mock that some permissions already exist - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, @@ -730,9 +760,15 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should update prompts (explicitly configured) and all other permissions that don't exist const expectedPermissionsForUser = { @@ -791,15 +827,15 @@ describe('loadDefaultInterface', () => { [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, }; - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.USER, expectedPermissionsForUser, expect.objectContaining({ permissions: expect.any(Object), }), ); - expect(updateAccessPermissions).toHaveBeenCalledWith( + expect(mockUpdateAccessPermissions).toHaveBeenCalledWith( SystemRoles.ADMIN, expectedPermissionsForAdmin, expect.objectContaining({ @@ -808,6 +844,39 @@ describe('loadDefaultInterface', () => { ); }); + it('should handle memories OPT_OUT based on personalization when memories are enabled', async () => { + const config = { + interface: { + memories: true, + }, + memory: { + // Memory enabled with personalization + enable: true, + personalize: true, + } as unknown as TCustomConfig['memory'], + }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + const userCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.USER, + ); + const adminCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.ADMIN, + ); + + // Both roles should have OPT_OUT set to true when personalization is enabled + expect(userCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); + expect(adminCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); + }); + it('should use role-specific defaults for PEOPLE_PICKER when peoplePicker config is undefined', async () => { const config = { interface: { @@ -816,17 +885,23 @@ describe('loadDefaultInterface', () => { bookmarks: true, }, }; - const configDefaults = { interface: {} }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - expect(updateAccessPermissions).toHaveBeenCalledTimes(2); + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); // Get the calls to updateAccessPermissions - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -845,6 +920,29 @@ describe('loadDefaultInterface', () => { }); }); + it('should only call getRoleByName once per role for efficiency', async () => { + const config = { + interface: { + prompts: true, + bookmarks: true, + }, + }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + // Should call getRoleByName exactly twice (once for USER, once for ADMIN) + expect(mockGetRoleByName).toHaveBeenCalledTimes(2); + expect(mockGetRoleByName).toHaveBeenCalledWith(SystemRoles.USER); + expect(mockGetRoleByName).toHaveBeenCalledWith(SystemRoles.ADMIN); + }); + it('should use role-specific defaults for complex permissions when not configured', async () => { const config = { interface: { @@ -874,14 +972,20 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -929,36 +1033,9 @@ describe('loadDefaultInterface', () => { }); }); - it('should handle memories OPT_OUT based on personalization when memories are enabled', async () => { - const config = { - interface: { - memories: true, - }, - memory: { - // Memory enabled with personalization - enable: true, - personalize: true, - }, - }; - const configDefaults = { interface: {} }; - - await loadDefaultInterface(config, configDefaults); - - const userCall = updateAccessPermissions.mock.calls.find( - (call) => call[0] === SystemRoles.USER, - ); - const adminCall = updateAccessPermissions.mock.calls.find( - (call) => call[0] === SystemRoles.ADMIN, - ); - - // Both roles should have OPT_OUT set to true when personalization is enabled - expect(userCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); - expect(adminCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true); - }); - it('should populate missing PEOPLE_PICKER and MARKETPLACE permissions with role-specific defaults', async () => { // Mock that PEOPLE_PICKER and MARKETPLACE permissions don't exist yet - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, @@ -985,14 +1062,20 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); - const adminCall = updateAccessPermissions.mock.calls.find( + const adminCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.ADMIN, ); @@ -1053,7 +1136,7 @@ describe('loadDefaultInterface', () => { [PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true }, }; - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: existingUserPermissions, }); @@ -1073,12 +1156,18 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); // Should only update permissions that don't exist - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); @@ -1094,26 +1183,9 @@ describe('loadDefaultInterface', () => { expect(userCall[1]).toHaveProperty(PermissionTypes.AGENTS); }); - it('should only call getRoleByName once per role for efficiency', async () => { - const config = { - interface: { - prompts: true, - bookmarks: true, - }, - }; - const configDefaults = { interface: {} }; - - await loadDefaultInterface(config, configDefaults); - - // Should call getRoleByName exactly twice (once for USER, once for ADMIN) - expect(getRoleByName).toHaveBeenCalledTimes(2); - expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.USER); - expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.ADMIN); - }); - it('should only update explicitly configured permissions and leave others unchanged', async () => { // Mock existing permissions - getRoleByName.mockResolvedValue({ + mockGetRoleByName.mockResolvedValue({ permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, @@ -1153,11 +1225,17 @@ describe('loadDefaultInterface', () => { use: false, }, }, - }; + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; - await loadDefaultInterface(config, configDefaults); + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); - const userCall = updateAccessPermissions.mock.calls.find( + const userCall = mockUpdateAccessPermissions.mock.calls.find( (call) => call[0] === SystemRoles.USER, ); diff --git a/api/server/services/start/interface.js b/packages/api/src/app/permissions.ts similarity index 52% rename from api/server/services/start/interface.js rename to packages/api/src/app/permissions.ts index b0e2aced9e..5b9e479867 100644 --- a/api/server/services/start/interface.js +++ b/packages/api/src/app/permissions.ts @@ -1,119 +1,112 @@ -const { +import { logger } from '@librechat/data-schemas'; +import { SystemRoles, Permissions, roleDefaults, PermissionTypes, - removeNullishValues, -} = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); -const { isMemoryEnabled } = require('@librechat/api'); -const { updateAccessPermissions, getRoleByName } = require('~/models/Role'); + getConfigDefaults, +} from 'librechat-data-provider'; +import type { IRole } from '@librechat/data-schemas'; +import type { AppConfig } from '~/types/config'; +import { isMemoryEnabled } from '~/memory/config'; /** * Checks if a permission type has explicit configuration */ -function hasExplicitConfig(interfaceConfig, permissionType) { +function hasExplicitConfig( + interfaceConfig: AppConfig['interfaceConfig'], + permissionType: PermissionTypes, +) { switch (permissionType) { case PermissionTypes.PROMPTS: - return interfaceConfig.prompts !== undefined; + return interfaceConfig?.prompts !== undefined; case PermissionTypes.BOOKMARKS: - return interfaceConfig.bookmarks !== undefined; + return interfaceConfig?.bookmarks !== undefined; case PermissionTypes.MEMORIES: - return interfaceConfig.memories !== undefined; + return interfaceConfig?.memories !== undefined; case PermissionTypes.MULTI_CONVO: - return interfaceConfig.multiConvo !== undefined; + return interfaceConfig?.multiConvo !== undefined; case PermissionTypes.AGENTS: - return interfaceConfig.agents !== undefined; + return interfaceConfig?.agents !== undefined; case PermissionTypes.TEMPORARY_CHAT: - return interfaceConfig.temporaryChat !== undefined; + return interfaceConfig?.temporaryChat !== undefined; case PermissionTypes.RUN_CODE: - return interfaceConfig.runCode !== undefined; + return interfaceConfig?.runCode !== undefined; case PermissionTypes.WEB_SEARCH: - return interfaceConfig.webSearch !== undefined; + return interfaceConfig?.webSearch !== undefined; case PermissionTypes.PEOPLE_PICKER: - return interfaceConfig.peoplePicker !== undefined; + return interfaceConfig?.peoplePicker !== undefined; case PermissionTypes.MARKETPLACE: - return interfaceConfig.marketplace !== undefined; + return interfaceConfig?.marketplace !== undefined; case PermissionTypes.FILE_SEARCH: - return interfaceConfig.fileSearch !== undefined; + return interfaceConfig?.fileSearch !== undefined; case PermissionTypes.FILE_CITATIONS: - return interfaceConfig.fileCitations !== undefined; + return interfaceConfig?.fileCitations !== undefined; default: return false; } } -/** - * Loads the default interface object. - * @param {TCustomConfig | undefined} config - The loaded custom configuration. - * @param {TConfigDefaults} configDefaults - The custom configuration default values. - * @returns {Promise} The default interface object. - */ -async function loadDefaultInterface(config, configDefaults) { - const { interface: interfaceConfig } = config ?? {}; - const { interface: defaults } = configDefaults; - const hasModelSpecs = config?.modelSpecs?.list?.length > 0; - const includesAddedEndpoints = config?.modelSpecs?.addedEndpoints?.length > 0; +export async function updateInterfacePermissions({ + appConfig, + getRoleByName, + updateAccessPermissions, +}: { + appConfig: AppConfig; + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; + updateAccessPermissions: ( + roleName: string, + permissionsUpdate: Partial>>, - const memoryConfig = config?.memory; + roleData?: IRole | null, + ) => Promise; +}) { + const loadedInterface = appConfig?.interfaceConfig; + if (!loadedInterface) { + return; + } + /** Configured values for interface object structure */ + const interfaceConfig = appConfig?.config?.interface; + const memoryConfig = appConfig?.config?.memory; const memoryEnabled = isMemoryEnabled(memoryConfig); - /** Only disable memories if memory config is present but disabled/invalid */ - const shouldDisableMemories = memoryConfig && !memoryEnabled; /** Check if personalization is enabled (defaults to true if memory is configured and enabled) */ const isPersonalizationEnabled = memoryConfig && memoryEnabled && memoryConfig.personalize !== false; - /** @type {TCustomConfig['interface']} */ - const loadedInterface = removeNullishValues({ - // UI elements - use schema defaults - endpointsMenu: - interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu), - modelSelect: - interfaceConfig?.modelSelect ?? - (hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect), - parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters), - presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets), - sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel, - privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, - termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, - mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers, - customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome, - - // Permissions - only include if explicitly configured - bookmarks: interfaceConfig?.bookmarks, - memories: shouldDisableMemories ? false : interfaceConfig?.memories, - prompts: interfaceConfig?.prompts, - multiConvo: interfaceConfig?.multiConvo, - agents: interfaceConfig?.agents, - temporaryChat: interfaceConfig?.temporaryChat, - runCode: interfaceConfig?.runCode, - webSearch: interfaceConfig?.webSearch, - fileSearch: interfaceConfig?.fileSearch, - fileCitations: interfaceConfig?.fileCitations, - peoplePicker: interfaceConfig?.peoplePicker, - marketplace: interfaceConfig?.marketplace, - }); - - // Helper to get permission value with proper precedence - const getPermissionValue = (configValue, roleDefault, schemaDefault) => { + /** Helper to get permission value with proper precedence */ + const getPermissionValue = ( + configValue?: boolean, + roleDefault?: boolean, + schemaDefault?: boolean, + ) => { if (configValue !== undefined) return configValue; if (roleDefault !== undefined) return roleDefault; return schemaDefault; }; + const defaults = getConfigDefaults().interface; + // Permission precedence order: // 1. Explicit user configuration (from librechat.yaml) // 2. Role-specific defaults (from roleDefaults) // 3. Interface schema defaults (from interfaceSchema.default()) for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) { - const defaultPerms = roleDefaults[roleName].permissions; - const existingRole = await getRoleByName(roleName); - const existingPermissions = existingRole?.permissions || {}; - const permissionsToUpdate = {}; + const defaultPerms = roleDefaults[roleName]?.permissions; - // Helper to add permission if it should be updated - const addPermissionIfNeeded = (permType, permissions) => { - const permTypeExists = existingPermissions[permType]; + const existingRole = await getRoleByName(roleName); + const existingPermissions = existingRole?.permissions; + const permissionsToUpdate: Partial< + Record> + > = {}; + + /** + * Helper to add permission if it should be updated + */ + const addPermissionIfNeeded = ( + permType: PermissionTypes, + permissions: Record, + ) => { + const permTypeExists = existingPermissions?.[permType]; const isExplicitlyConfigured = interfaceConfig && hasExplicitConfig(interfaceConfig, permType); @@ -130,8 +123,7 @@ async function loadDefaultInterface(config, configDefaults) { } }; - // Build permissions for each type - const allPermissions = { + const allPermissions: Partial>> = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: getPermissionValue( loadedInterface.prompts, @@ -240,7 +232,7 @@ async function loadDefaultInterface(config, configDefaults) { // Check and add each permission type if needed for (const [permType, permissions] of Object.entries(allPermissions)) { - addPermissionIfNeeded(permType, permissions); + addPermissionIfNeeded(permType as PermissionTypes, permissions); } // Update permissions if any need updating @@ -248,54 +240,4 @@ async function loadDefaultInterface(config, configDefaults) { await updateAccessPermissions(roleName, permissionsToUpdate, existingRole); } } - - let i = 0; - const logSettings = () => { - // log interface object and model specs object (without list) for reference - logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); - logger.warn( - `\`modelSpecs\` settings:\n${JSON.stringify( - { ...(config?.modelSpecs ?? {}), list: undefined }, - null, - 2, - )}`, - ); - }; - - // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. - if (config?.modelSpecs?.prioritize && loadedInterface.presets) { - logger.warn( - "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", - ); - i === 0 && i++; - } - - // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. - if ( - config?.modelSpecs?.enforce && - (loadedInterface.endpointsMenu || - loadedInterface.modelSelect || - loadedInterface.presets || - loadedInterface.parameters) - ) { - logger.warn( - "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", - ); - i === 0 && i++; - } - // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. - if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { - logger.warn( - "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", - ); - i === 0 && i++; - } - - if (i > 0) { - logSettings(); - } - - return loadedInterface; } - -module.exports = { loadDefaultInterface };