diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 708d114e72..bd1664fc90 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -48,7 +48,7 @@ function createHandlers(overrides = {}) { }), patchConfigFields: jest .fn() - .mockResolvedValue({ _id: 'c1', overrides: { interface: { endpointsMenu: false } } }), + .mockResolvedValue({ _id: 'c1', overrides: { registration: { enabled: false } } }), unsetConfigField: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }), deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }), toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }), @@ -169,6 +169,93 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(400); }); + + it('strips permission fields from interface overrides but keeps UI fields', async () => { + const { handlers, deps } = createHandlers({ + upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + interface: { endpointsMenu: false, prompts: false, agents: { use: false } }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + expect(savedOverrides.interface).toEqual({ endpointsMenu: false }); + }); + + it('preserves UI sub-keys in composite permission fields like mcpServers', async () => { + const { handlers, deps } = createHandlers({ + upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + interface: { + mcpServers: { + use: true, + create: false, + placeholder: 'Search MCP...', + trustCheckbox: { label: 'Trust' }, + }, + }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + const mcp = (savedOverrides as Record).interface as Record; + expect((mcp.mcpServers as Record).placeholder).toBe('Search MCP...'); + expect((mcp.mcpServers as Record).trustCheckbox).toEqual({ label: 'Trust' }); + expect((mcp.mcpServers as Record).use).toBeUndefined(); + expect((mcp.mcpServers as Record).create).toBeUndefined(); + }); + + it('strips peoplePicker permission sub-keys in upsert', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + interface: { peoplePicker: { users: false, groups: true, roles: true } }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('returns 200 with message when only permission fields in interface', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: { interface: { prompts: false, agents: false } } }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); }); describe('deleteConfigField', () => { @@ -189,6 +276,87 @@ describe('createAdminConfigHandlers', () => { ); }); + it('allows deleting mcpServers UI sub-key paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.mcpServers.placeholder' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.unsetConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + 'interface.mcpServers.placeholder', + ); + }); + + it('blocks deleting mcpServers permission sub-key paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.mcpServers.use' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); + }); + + it('blocks deleting peoplePicker permission sub-key paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.peoplePicker.users' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); + }); + + it('returns 200 no-op for interface permission field path', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.prompts' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); + }); + + it('allows deleting interface UI field paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.endpointsMenu' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.unsetConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + 'interface.endpointsMenu', + ); + }); + it('returns 400 when fieldPath query param is missing', async () => { const { handlers } = createHandlers(); const req = mockReq({ @@ -224,7 +392,110 @@ describe('createAdminConfigHandlers', () => { }); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, - body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] }, + body: { entries: [{ fieldPath: 'registration.enabled', value: false }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(403); + }); + + it('strips interface permission field entries but keeps UI field entries', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { fieldPath: 'interface.endpointsMenu', value: false }, + { fieldPath: 'interface.prompts', value: false }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields['interface.endpointsMenu']).toBe(false); + expect(patchedFields['interface.prompts']).toBeUndefined(); + }); + + it('blocks peoplePicker permission sub-key paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'interface.peoplePicker.users', value: false }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('allows mcpServers UI sub-key paths but blocks permission sub-key paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { fieldPath: 'interface.mcpServers.placeholder', value: 'Search...' }, + { fieldPath: 'interface.mcpServers.use', value: true }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields['interface.mcpServers.placeholder']).toBe('Search...'); + expect(patchedFields['interface.mcpServers.use']).toBeUndefined(); + }); + + it('returns 200 with message when all entries are permission fields', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.prompts', value: false }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body!.message).toBeDefined(); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('returns 401 when unauthenticated even if all entries are permission fields', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.prompts', value: false }] }, + user: undefined, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(401); + }); + + it('returns 403 when unauthorized even if all entries are permission fields', async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.prompts', value: false }] }, }); const res = mockRes(); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index b2afd9c69b..cca2d9901c 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -1,5 +1,10 @@ import { logger } from '@librechat/data-schemas'; -import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import { + PrincipalType, + PrincipalModel, + INTERFACE_PERMISSION_FIELDS, + PERMISSION_SUB_KEYS, +} from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig, ConfigSection, IConfig } from '@librechat/data-schemas'; import type { Types, ClientSession } from 'mongoose'; @@ -26,6 +31,33 @@ export function getTopLevelSection(fieldPath: string): string { return fieldPath.split('.')[0]; } +/** + * Returns true if `fieldPath` targets an interface permission field or permission sub-key. + * + * - `"interface.prompts"` → true (boolean permission field) + * - `"interface.agents.use"` → true (permission sub-key) + * - `"interface.mcpServers"` → true (entire composite field) + * - `"interface.mcpServers.use"` → true (permission sub-key) + * - `"interface.mcpServers.placeholder"` → false (UI-only sub-key) + * - `"interface.peoplePicker.users"` → true (all peoplePicker sub-keys are permissions) + * - `"interface.endpointsMenu"` → false (UI-only field) + */ +function isInterfacePermissionPath(fieldPath: string): boolean { + const parts = fieldPath.split('.'); + if (parts[0] !== 'interface' || parts.length < 2) { + return false; + } + if (!INTERFACE_PERMISSION_FIELDS.has(parts[1])) { + return false; + } + // "interface." with no sub-key → permission (blocks the whole field) + if (parts.length === 2) { + return true; + } + // "interface.." → only block if sub-key is a permission bit + return PERMISSION_SUB_KEYS.has(parts[2]); +} + export interface AdminConfigDeps { listAllConfigs: (filter?: { isActive?: boolean }, session?: ClientSession) => Promise; findConfigByPrincipal: ( @@ -262,24 +294,64 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) { return res.status(403).json({ error: 'Insufficient permissions' }); } - const overrideSections = Object.keys(overrides); - if (overrideSections.length > 0) { - const allowed = await Promise.all( - overrideSections.map((s) => hasConfigCapability(user, s as ConfigSection, 'manage')), - ); - const denied = overrideSections.find((_, i) => !allowed[i]); - if (denied) { - return res.status(403).json({ - error: `Insufficient permissions for config section: ${denied}`, - }); + let filteredOverrides = overrides; + const iface = (overrides as Record).interface; + if (iface != null && typeof iface === 'object' && !Array.isArray(iface)) { + const filteredIface: Record = {}; + for (const [field, val] of Object.entries(iface as Record)) { + if (!INTERFACE_PERMISSION_FIELDS.has(field)) { + filteredIface[field] = val; + } else if (val != null && typeof val === 'object' && !Array.isArray(val)) { + // Composite permission field (e.g. mcpServers): strip permission + // sub-keys but preserve UI-only sub-keys like placeholder/trustCheckbox. + const uiOnly: Record = {}; + for (const [sub, subVal] of Object.entries(val as Record)) { + if (!PERMISSION_SUB_KEYS.has(sub)) { + uiOnly[sub] = subVal; + } else { + logger.warn( + `[adminConfig] Stripping interface permission sub-field "${field}.${sub}" — use role permissions instead`, + ); + } + } + if (Object.keys(uiOnly).length > 0) { + filteredIface[field] = uiOnly; + } + } else { + logger.warn( + `[adminConfig] Stripping interface permission field "${field}" — use role permissions instead`, + ); + } } + filteredOverrides = { ...(overrides as Record) } as Partial; + if (Object.keys(filteredIface).length > 0) { + (filteredOverrides as Record).interface = filteredIface; + } else { + delete (filteredOverrides as Record).interface; + } + } + + const overrideSections = Object.keys(filteredOverrides); + + if (overrideSections.length === 0) { + return res.status(200).json({ message: 'No actionable override sections provided' }); + } + + const allowed = await Promise.all( + overrideSections.map((s) => hasConfigCapability(user, s as ConfigSection, 'manage')), + ); + const denied = overrideSections.find((_, i) => !allowed[i]); + if (denied) { + return res.status(403).json({ + error: `Insufficient permissions for config section: ${denied}`, + }); } const config = await upsertConfig( principalType, principalId, principalModel(principalType), - overrides, + filteredOverrides, priority ?? DEFAULT_PRIORITY, ); @@ -339,8 +411,25 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) { return res.status(401).json({ error: 'Authentication required' }); } + const validEntries = entries.filter((entry) => { + if (isInterfacePermissionPath(entry.fieldPath)) { + logger.warn( + `[adminConfig] Stripping interface permission field "${entry.fieldPath}" — use role permissions instead`, + ); + return false; + } + return true; + }); + + if (validEntries.length === 0) { + if (!(await hasConfigCapability(user, null, 'manage'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + return res.status(200).json({ message: 'No actionable field entries provided' }); + } + if (!(await hasConfigCapability(user, null, 'manage'))) { - const sections = [...new Set(entries.map((e) => getTopLevelSection(e.fieldPath)))]; + const sections = [...new Set(validEntries.map((e) => getTopLevelSection(e.fieldPath)))]; const allowed = await Promise.all( sections.map((s) => hasConfigCapability(user, s as ConfigSection, 'manage')), ); @@ -354,7 +443,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) { const seen = new Set(); const fields: Record = {}; - for (const entry of entries) { + for (const entry of validEntries) { if (seen.has(entry.fieldPath)) { return res.status(400).json({ error: `Duplicate fieldPath: ${entry.fieldPath}` }); } @@ -414,12 +503,20 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) { } const section = getTopLevelSection(fieldPath); + if (!(await hasConfigCapability(user, section as ConfigSection, 'manage'))) { return res.status(403).json({ error: `Insufficient permissions for config section: ${section}`, }); } + if (isInterfacePermissionPath(fieldPath)) { + logger.warn( + `[adminConfig] Ignoring delete for interface permission field "${fieldPath}" — use role permissions instead`, + ); + return res.status(200).json({ message: 'No actionable field path provided' }); + } + const config = await unsetConfigField(principalType, principalId, fieldPath); if (!config) { return res.status(404).json({ error: 'Config not found' }); diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 0cbe9258f2..4a62f898c8 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.405", + "version": "0.8.406", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/permissions.ts b/packages/data-provider/src/permissions.ts index 7a3144c82d..58cf0df15d 100644 --- a/packages/data-provider/src/permissions.ts +++ b/packages/data-provider/src/permissions.ts @@ -62,6 +62,55 @@ export enum PermissionTypes { REMOTE_AGENTS = 'REMOTE_AGENTS', } +/** + * Maps PermissionTypes to their corresponding `interface` config field names. + * Used to identify which interface fields seed role permissions at startup + * and must NOT be overridden via DB config (use the role permissions editor instead). + */ +export const PERMISSION_TYPE_INTERFACE_FIELDS: Record = { + [PermissionTypes.PROMPTS]: 'prompts', + [PermissionTypes.AGENTS]: 'agents', + [PermissionTypes.BOOKMARKS]: 'bookmarks', + [PermissionTypes.MEMORIES]: 'memories', + [PermissionTypes.MULTI_CONVO]: 'multiConvo', + [PermissionTypes.TEMPORARY_CHAT]: 'temporaryChat', + [PermissionTypes.RUN_CODE]: 'runCode', + [PermissionTypes.WEB_SEARCH]: 'webSearch', + [PermissionTypes.FILE_SEARCH]: 'fileSearch', + [PermissionTypes.FILE_CITATIONS]: 'fileCitations', + [PermissionTypes.PEOPLE_PICKER]: 'peoplePicker', + [PermissionTypes.MARKETPLACE]: 'marketplace', + [PermissionTypes.MCP_SERVERS]: 'mcpServers', + [PermissionTypes.REMOTE_AGENTS]: 'remoteAgents', +}; + +/** Set of interface config field names that correspond to role permissions. */ +export const INTERFACE_PERMISSION_FIELDS = new Set(Object.values(PERMISSION_TYPE_INTERFACE_FIELDS)); + +/** + * YAML sub-keys within composite interface permission fields that map to permission bits. + * When an interface permission field is an object, only these sub-keys are stripped from + * DB overrides — other sub-keys (like `placeholder`, `trustCheckbox`) are UI-only and pass through. + * + * Mapping to Permissions enum: + * 'use' → Permissions.USE (agents, prompts, mcpServers, remoteAgents, marketplace) + * 'create' → Permissions.CREATE (agents, prompts, mcpServers, remoteAgents) + * 'share' → Permissions.SHARE (agents, prompts, mcpServers, remoteAgents) + * 'public' → Permissions.SHARE_PUBLIC (agents, prompts, mcpServers, remoteAgents) + * 'users' → Permissions.VIEW_USERS (peoplePicker only) + * 'groups' → Permissions.VIEW_GROUPS (peoplePicker only) + * 'roles' → Permissions.VIEW_ROLES (peoplePicker only) + */ +export const PERMISSION_SUB_KEYS = new Set([ + 'use', + 'create', + 'share', + 'public', + 'users', + 'groups', + 'roles', +]); + /** * Enum for Role-Based Access Control Constants */ diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 145b8925d1..652c4e0867 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.47", + "version": "0.0.48", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index b37ff25e3a..991f6afb40 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -1,3 +1,4 @@ +import { INTERFACE_PERMISSION_FIELDS, PermissionTypes } from 'librechat-data-provider'; import { mergeConfigOverrides } from './resolution'; import type { AppConfig, IConfig } from '~/types'; @@ -30,7 +31,7 @@ describe('mergeConfigOverrides', () => { expect(mergeConfigOverrides(baseConfig, undefined as unknown as IConfig[])).toBe(baseConfig); }); - it('deep merges a single override into base', () => { + it('deep merges interface UI fields into interfaceConfig', () => { const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; const iface = result.interfaceConfig as Record; @@ -134,6 +135,104 @@ describe('mergeConfigOverrides', () => { expect(result.turnstile).toBeUndefined(); }); + it('strips interface permission fields from overrides', () => { + const base = { + interfaceConfig: { endpointsMenu: true, sidePanel: true }, + } as unknown as AppConfig; + + const configs = [ + fakeConfig( + { + interface: { + endpointsMenu: false, + prompts: false, + agents: { use: false }, + marketplace: { use: false }, + }, + }, + 10, + ), + ]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const iface = result.interfaceConfig as Record; + + // UI field should be merged + expect(iface.endpointsMenu).toBe(false); + // Boolean permission fields should be stripped + expect(iface.prompts).toBeUndefined(); + // Object permission fields with only permission sub-keys should be stripped + expect(iface.agents).toBeUndefined(); + expect(iface.marketplace).toBeUndefined(); + // Untouched base field preserved + expect(iface.sidePanel).toBe(true); + }); + + it('preserves UI sub-keys in composite permission fields like mcpServers', () => { + const base = { + interfaceConfig: {}, + } as unknown as AppConfig; + + const configs = [ + fakeConfig( + { + interface: { + mcpServers: { + use: true, + create: false, + share: false, + public: false, + placeholder: 'Search MCP servers...', + trustCheckbox: { label: 'I trust this server' }, + }, + }, + }, + 10, + ), + ]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const iface = result.interfaceConfig as Record; + const mcp = iface.mcpServers as Record; + + // UI sub-keys preserved + expect(mcp.placeholder).toBe('Search MCP servers...'); + expect(mcp.trustCheckbox).toEqual({ label: 'I trust this server' }); + // Permission sub-keys stripped + expect(mcp.use).toBeUndefined(); + expect(mcp.create).toBeUndefined(); + expect(mcp.share).toBeUndefined(); + expect(mcp.public).toBeUndefined(); + }); + + it('strips peoplePicker permission sub-keys (users, groups, roles)', () => { + const base = { + interfaceConfig: {}, + } as unknown as AppConfig; + + const configs = [ + fakeConfig({ interface: { peoplePicker: { users: false, groups: true, roles: true } } }, 10), + ]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const iface = result.interfaceConfig as Record; + + // All sub-keys are permission bits → entire field stripped + expect(iface.peoplePicker).toBeUndefined(); + }); + + it('drops interface entirely when only permission fields are present', () => { + const base = { + interfaceConfig: { endpointsMenu: true }, + } as unknown as AppConfig; + + const configs = [fakeConfig({ interface: { prompts: false, agents: false } }, 10)]; + const result = mergeConfigOverrides(base, configs) as unknown as Record; + const iface = result.interfaceConfig as Record; + + // Base should be unchanged + expect(iface.endpointsMenu).toBe(true); + expect(iface.prompts).toBeUndefined(); + expect(iface.agents).toBeUndefined(); + }); + it('remaps YAML-level keys to AppConfig equivalents', () => { const configs = [ fakeConfig( @@ -153,3 +252,38 @@ describe('mergeConfigOverrides', () => { expect(result.mcpServers).toBeUndefined(); }); }); + +describe('INTERFACE_PERMISSION_FIELDS', () => { + it('contains all expected permission fields', () => { + const expected = [ + 'prompts', + 'agents', + 'bookmarks', + 'memories', + 'multiConvo', + 'temporaryChat', + 'runCode', + 'webSearch', + 'fileSearch', + 'fileCitations', + 'peoplePicker', + 'marketplace', + 'mcpServers', + 'remoteAgents', + ]; + for (const field of expected) { + expect(INTERFACE_PERMISSION_FIELDS.has(field)).toBe(true); + } + }); + + it('has one entry per PermissionType — no duplicates or missing', () => { + expect(INTERFACE_PERMISSION_FIELDS.size).toBe(Object.values(PermissionTypes).length); + }); + + it('does not contain UI-only fields', () => { + const uiFields = ['endpointsMenu', 'modelSelect', 'parameters', 'presets', 'sidePanel']; + for (const field of uiFields) { + expect(INTERFACE_PERMISSION_FIELDS.has(field)).toBe(false); + } + }); +}); diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts index be35a1d706..08b4d1b12d 100644 --- a/packages/data-schemas/src/app/resolution.ts +++ b/packages/data-schemas/src/app/resolution.ts @@ -1,3 +1,4 @@ +import { INTERFACE_PERMISSION_FIELDS, PERMISSION_SUB_KEYS } from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig, IConfig } from '~/types'; @@ -65,7 +66,42 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): if (config.overrides && typeof config.overrides === 'object') { const remapped: AnyObject = {}; for (const [key, value] of Object.entries(config.overrides)) { - remapped[OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key] = value; + const mappedKey = OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key; + if ( + key === 'interface' && + value != null && + typeof value === 'object' && + !Array.isArray(value) + ) { + const filtered: AnyObject = {}; + for (const [field, fieldVal] of Object.entries(value as AnyObject)) { + if (!INTERFACE_PERMISSION_FIELDS.has(field)) { + filtered[field] = fieldVal; + } else if ( + fieldVal != null && + typeof fieldVal === 'object' && + !Array.isArray(fieldVal) + ) { + // Composite permission field (e.g. mcpServers): strip permission + // sub-keys but preserve UI-only sub-keys like placeholder/trustCheckbox. + const uiOnly: AnyObject = {}; + for (const [sub, subVal] of Object.entries(fieldVal as AnyObject)) { + if (!PERMISSION_SUB_KEYS.has(sub)) { + uiOnly[sub] = subVal; + } + } + if (Object.keys(uiOnly).length > 0) { + filtered[field] = uiOnly; + } + } + // boolean permission fields (e.g. runCode: false) are fully stripped + } + if (Object.keys(filtered).length > 0) { + remapped[mappedKey] = filtered; + } + } else { + remapped[mappedKey] = value; + } } merged = deepMerge(merged, remapped); }