diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js index d934645f18..ab7aa01a2b 100644 --- a/api/server/routes/admin/config.js +++ b/api/server/routes/admin/config.js @@ -2,6 +2,7 @@ const express = require('express'); const { createAdminConfigHandlers } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { + hasCapability, hasConfigCapability, requireCapability, } = require('~/server/middleware/roles/capabilities'); @@ -23,6 +24,7 @@ const handlers = createAdminConfigHandlers({ deleteConfig: db.deleteConfig, toggleConfigActive: db.toggleConfigActive, hasConfigCapability, + hasCapability, getAppConfig, invalidateConfigCaches, }); diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 7649d10ae9..23d595de89 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -56,6 +56,7 @@ function createHandlers(overrides = {}) { deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }), toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }), hasConfigCapability: jest.fn().mockResolvedValue(true), + hasCapability: jest.fn().mockResolvedValue(true), getAppConfig: jest.fn().mockResolvedValue({ interface: { modelSelect: true } }), ...overrides, @@ -819,6 +820,7 @@ describe('createAdminConfigHandlers', () => { it('returns 403 for empty overrides when user lacks MANAGE_CONFIGS', async () => { const { handlers } = createHandlers({ hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), }); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, @@ -853,7 +855,15 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(201); expect(res.body).toHaveProperty('config'); expect(res.body?.config).toHaveProperty('_id', 'c1'); - expect(upsertConfig).toHaveBeenCalledWith('role', 'admin', expect.anything(), {}, 5); + expect(upsertConfig).toHaveBeenCalledWith( + 'role', + 'admin', + expect.anything(), + {}, + 5, + undefined, + expect.objectContaining({ expectEmpty: expect.any(Boolean) }), + ); }); it('returns no-op message when overrides is empty and no priority is provided', async () => { @@ -892,6 +902,7 @@ describe('createAdminConfigHandlers', () => { it('returns 403 for empty overrides with priority when user lacks MANAGE_CONFIGS', async () => { const { handlers } = createHandlers({ hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), }); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, @@ -968,6 +979,418 @@ describe('createAdminConfigHandlers', () => { }, ]; + describe('upsertConfigOverrides: scope-lifecycle auth (ASSIGN_CONFIGS)', () => { + it('allows empty-overrides scope creation when caller has ASSIGN_CONFIGS only', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + expect(deps.upsertConfig).toHaveBeenCalled(); + }); + + it('rejects non-empty overrides for ASSIGN_CONFIGS-only caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: { memory: { enabled: true } } }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('rejects empty-overrides scope creation when caller has neither grant', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + }); + + describe('deleteConfigOverrides: accepts ASSIGN_CONFIGS', () => { + it('allows delete when caller has ASSIGN_CONFIGS only', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.deleteConfig).toHaveBeenCalled(); + }); + + it('rejects delete when caller has neither broad manage nor ASSIGN_CONFIGS', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.deleteConfig).not.toHaveBeenCalled(); + }); + }); + + describe('toggleConfig: accepts ASSIGN_CONFIGS', () => { + it('allows toggle when caller has ASSIGN_CONFIGS only', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { isActive: false }, + }); + const res = mockRes(); + + await handlers.toggleConfig(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.toggleConfigActive).toHaveBeenCalled(); + }); + + it('rejects toggle when caller has neither broad manage nor ASSIGN_CONFIGS', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { isActive: false }, + }); + const res = mockRes(); + + await handlers.toggleConfig(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.toggleConfigActive).not.toHaveBeenCalled(); + }); + }); + + describe('scope-lifecycle: parameterized assign:configs check', () => { + it('allows upsert when caller holds assign:configs:', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn(async (_user, cap) => cap === 'assign:configs:role'), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + expect(deps.upsertConfig).toHaveBeenCalled(); + }); + + it('rejects upsert when parameterized grant targets a different principalType', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn(async (_user, cap) => cap === 'assign:configs:user'), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('queries hasCapability with the principalType-parameterized form', async () => { + const hasCap = jest.fn().mockResolvedValue(true); + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: hasCap, + }); + const req = mockReq({ + params: { principalType: 'group', principalId: 'engineers' }, + body: { isActive: false }, + }); + const res = mockRes(); + + await handlers.toggleConfig(req, res); + + const queriedCapabilities = hasCap.mock.calls.map((call) => call[1]); + expect(queriedCapabilities).toContain('assign:configs:group'); + }); + }); + + describe('scope-lifecycle: __base__ short-circuit', () => { + it('upsert against __base__ returns 403 for assign-only caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('delete against __base__ returns 403 for assign-only caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.deleteConfig).not.toHaveBeenCalled(); + }); + + it('toggle against __base__ returns 403 for assign-only caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { isActive: false }, + }); + const res = mockRes(); + + await handlers.toggleConfig(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.toggleConfigActive).not.toHaveBeenCalled(); + }); + + it('upsert against __base__ succeeds for broad-manage caller', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(true), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: '__base__' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + expect(deps.upsertConfig).toHaveBeenCalled(); + }); + }); + + describe('scope-lifecycle: atomic empty-state guard for assign-only callers', () => { + it('empty-overrides upsert is rejected when atomic filter mismatches (existing doc not empty)', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + upsertConfig: jest.fn(async (_pt, _pi, _pm, _o, _p, _session, opts) => { + return opts?.expectEmpty ? null : { _id: 'c1', configVersion: 1 }; + }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + undefined, + { expectEmpty: true }, + ); + }); + + it('delete is rejected with 403 when atomic filter mismatches and doc still exists', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + deleteConfig: jest.fn(async (_pt, _pi, _session, opts) => { + return opts?.expectEmpty ? null : { _id: 'c1' }; + }), + findConfigByPrincipal: jest.fn().mockResolvedValue({ + _id: 'c1', + overrides: { endpoints: { custom: true } }, + }), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.deleteConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + { expectEmpty: true }, + ); + }); + + it('toggle is rejected with 403 when atomic filter mismatches and doc still exists', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + toggleConfigActive: jest.fn(async (_pt, _pi, _isActive, _session, opts) => { + return opts?.expectEmpty ? null : { _id: 'c1', isActive: false }; + }), + findConfigByPrincipal: jest.fn().mockResolvedValue({ + _id: 'c1', + tombstones: ['endpoints.openai.apiKey'], + }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { isActive: false }, + }); + const res = mockRes(); + + await handlers.toggleConfig(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.toggleConfigActive).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + undefined, + { expectEmpty: true }, + ); + }); + + it('delete returns 404 when atomic filter mismatches and doc does not exist', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + deleteConfig: jest.fn().mockResolvedValue(null), + findConfigByPrincipal: jest.fn().mockResolvedValue(null), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(404); + expect(deps.findConfigByPrincipal).toHaveBeenCalled(); + }); + + it('delete succeeds for assign-only caller when atomic filter matches', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(true), + deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.deleteConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + { expectEmpty: true }, + ); + }); + + it('broad-manage caller calls destructive op without expectEmpty option', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(true), + deleteConfig: jest.fn().mockResolvedValue({ + _id: 'c1', + overrides: { endpoints: { custom: true } }, + }), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.deleteConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.deleteConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + { expectEmpty: false }, + ); + }); + }); + + describe('AdminConfigDeps.hasCapability is optional', () => { + it('factory accepts deps without hasCapability and falls back to broad-manage-only auth', async () => { + const deps = { + listAllConfigs: jest.fn().mockResolvedValue([]), + findConfigByPrincipal: jest.fn().mockResolvedValue(null), + upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }), + patchConfigFields: jest.fn(), + tombstoneConfigField: jest.fn(), + unsetConfigField: jest.fn(), + deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }), + toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }), + hasConfigCapability: jest.fn().mockResolvedValue(false), + }; + const handlers = createAdminConfigHandlers(deps); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {}, priority: 5 }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + }); + describe('invariant: all mutation handlers return 401 without auth', () => { for (const { name, reqOverrides } of MUTATION_HANDLERS) { it(`${name} returns 401 when user is missing`, async () => { @@ -990,6 +1413,7 @@ describe('createAdminConfigHandlers', () => { it(`${name} returns 403 when user lacks capability`, async () => { const { handlers } = createHandlers({ hasConfigCapability: jest.fn().mockResolvedValue(false), + hasCapability: jest.fn().mockResolvedValue(false), }); const req = mockReq(reqOverrides); const res = mockRes(); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index de0b57b525..f19c28940b 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -1,4 +1,4 @@ -import { logger } from '@librechat/data-schemas'; +import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas'; import { BASE_ONLY_CONFIG_SECTIONS, PrincipalType, @@ -6,7 +6,7 @@ import { INTERFACE_PERMISSION_FIELDS, PERMISSION_SUB_KEYS, } from 'librechat-data-provider'; -import type { AppConfig, ConfigSection, IConfig } from '@librechat/data-schemas'; +import type { AppConfig, ConfigSection, IConfig, SystemCapability } from '@librechat/data-schemas'; import type { TCustomConfig } from 'librechat-data-provider'; import type { Types, ClientSession } from 'mongoose'; import type { Response } from 'express'; @@ -79,6 +79,7 @@ export interface AdminConfigDeps { overrides: Partial, priority: number, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; patchConfigFields: ( principalType: PrincipalType, @@ -106,18 +107,21 @@ export interface AdminConfigDeps { principalType: PrincipalType, principalId: string | Types.ObjectId, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; toggleConfigActive: ( principalType: PrincipalType, principalId: string | Types.ObjectId, isActive: boolean, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; hasConfigCapability: ( user: CapabilityUser, section: ConfigSection | null, verb?: 'manage' | 'read', ) => Promise; + hasCapability?: (user: CapabilityUser, capability: SystemCapability) => Promise; getAppConfig?: (options?: { role?: string; userId?: string; @@ -192,6 +196,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { deleteConfig, toggleConfigActive, hasConfigCapability, + hasCapability = async () => false, getAppConfig, invalidateConfigCaches, } = deps; @@ -318,7 +323,17 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(401).json({ error: 'Authentication required' }); } - if (!(await hasConfigCapability(user, null, 'manage'))) { + const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const hasAssignConfigs = + hasBroadManage || + (await hasCapability(user, `assign:configs:${principalType}` as SystemCapability)); + + if (!hasAssignConfigs) { return res.status(403).json({ error: 'Insufficient permissions' }); } @@ -374,16 +389,8 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(200).json({ message: 'No actionable override sections provided' }); } - 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}`, - }); - } + if (overrideSections.length > 0 && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); } const config = await upsertConfig( @@ -392,7 +399,12 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { principalModel(principalType), filteredOverrides, priority ?? DEFAULT_PRIORITY, + undefined, + { expectEmpty: !hasBroadManage }, ); + if (!config && !hasBroadManage) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } invalidateConfigCaches?.(user.tenantId)?.catch((err) => logger.error('[adminConfig] Cache invalidation failed after upsert:', err), @@ -697,12 +709,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(401).json({ error: 'Authentication required' }); } - if (!(await hasConfigCapability(user, null, 'manage'))) { + const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { return res.status(403).json({ error: 'Insufficient permissions' }); } - const config = await deleteConfig(principalType, principalId); + const allowed = + hasBroadManage || + (await hasCapability(user, `assign:configs:${principalType}` as SystemCapability)); + if (!allowed) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const config = await deleteConfig(principalType, principalId, undefined, { + expectEmpty: !hasBroadManage, + }); if (!config) { + if (!hasBroadManage) { + const exists = await findConfigByPrincipal(principalType, principalId, { + includeInactive: true, + }); + if (exists) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + } return res.status(404).json({ error: 'Config not found' }); } @@ -740,12 +771,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(401).json({ error: 'Authentication required' }); } - if (!(await hasConfigCapability(user, null, 'manage'))) { + const hasBroadManage = await hasConfigCapability(user, null, 'manage'); + + if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) { return res.status(403).json({ error: 'Insufficient permissions' }); } - const config = await toggleConfigActive(principalType, principalId, isActive); + const allowed = + hasBroadManage || + (await hasCapability(user, `assign:configs:${principalType}` as SystemCapability)); + if (!allowed) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const config = await toggleConfigActive(principalType, principalId, isActive, undefined, { + expectEmpty: !hasBroadManage, + }); if (!config) { + if (!hasBroadManage) { + const exists = await findConfigByPrincipal(principalType, principalId, { + includeInactive: true, + }); + if (exists) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + } return res.status(404).json({ error: 'Config not found' }); } diff --git a/packages/data-schemas/src/methods/config.spec.ts b/packages/data-schemas/src/methods/config.spec.ts index 4d7ebfad9d..35cc3cece7 100644 --- a/packages/data-schemas/src/methods/config.spec.ts +++ b/packages/data-schemas/src/methods/config.spec.ts @@ -1,9 +1,9 @@ import mongoose, { Types } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { IConfig } from '~/types'; import { createConfigMethods } from './config'; import configSchema from '~/schema/config'; -import type { IConfig } from '~/types'; let mongoServer: MongoMemoryServer; let methods: ReturnType; @@ -476,3 +476,139 @@ describe('toggleConfigActive', () => { expect(result!.isActive).toBe(true); }); }); + +describe('expectEmpty atomic guard', () => { + it('deleteConfig with expectEmpty matches and deletes an empty doc', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + + const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, { + expectEmpty: true, + }); + expect(result).toBeTruthy(); + + const remaining = await mongoose.models.Config.countDocuments({}); + expect(remaining).toBe(0); + }); + + it('deleteConfig with expectEmpty returns null when overrides is non-empty (doc preserved)', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { modelSelect: false } }, + 10, + ); + + const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, { + expectEmpty: true, + }); + expect(result).toBeNull(); + + const remaining = await mongoose.models.Config.countDocuments({}); + expect(remaining).toBe(1); + }); + + it('deleteConfig with expectEmpty returns null when tombstones is non-empty (doc preserved)', async () => { + await mongoose.models.Config.create({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + principalModel: PrincipalModel.ROLE, + overrides: {}, + tombstones: ['endpoints.openai.apiKey'], + priority: 10, + isActive: true, + configVersion: 1, + }); + + const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, { + expectEmpty: true, + }); + expect(result).toBeNull(); + + const remaining = await mongoose.models.Config.countDocuments({}); + expect(remaining).toBe(1); + }); + + it('toggleConfigActive with expectEmpty matches and toggles an empty doc', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + + const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false, undefined, { + expectEmpty: true, + }); + expect(result).toBeTruthy(); + expect(result!.isActive).toBe(false); + }); + + it('toggleConfigActive with expectEmpty returns null on non-empty doc (isActive preserved)', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { modelSelect: false } }, + 10, + ); + + const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false, undefined, { + expectEmpty: true, + }); + expect(result).toBeNull(); + + const doc = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin'); + expect(doc!.isActive).toBe(true); + }); + + it('upsertConfig with expectEmpty inserts when no doc exists', async () => { + const result = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + {}, + 10, + undefined, + { expectEmpty: true }, + ); + expect(result).toBeTruthy(); + expect(result!.principalId).toBe('admin'); + }); + + it('upsertConfig with expectEmpty updates an empty existing doc', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 5); + + const result = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + {}, + 99, + undefined, + { expectEmpty: true }, + ); + expect(result).toBeTruthy(); + expect(result!.priority).toBe(99); + }); + + it('upsertConfig with expectEmpty returns null when existing doc has non-empty overrides', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { modelSelect: false } }, + 10, + ); + + const result = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + {}, + 99, + undefined, + { expectEmpty: true }, + ); + expect(result).toBeNull(); + + const doc = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin'); + expect(doc!.priority).toBe(10); + expect(Object.keys(doc!.overrides ?? {}).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/data-schemas/src/methods/config.ts b/packages/data-schemas/src/methods/config.ts index ca7bf015fd..62f6c10e96 100644 --- a/packages/data-schemas/src/methods/config.ts +++ b/packages/data-schemas/src/methods/config.ts @@ -1,7 +1,7 @@ import { Types } from 'mongoose'; import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { FilterQuery, Model, ClientSession } from 'mongoose'; import type { TCustomConfig } from 'librechat-data-provider'; -import type { Model, ClientSession } from 'mongoose'; import type { IConfig } from '~/types'; import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; import { escapeRegExp } from '~/utils/string'; @@ -37,6 +37,7 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { overrides: Partial, priority: number, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; patchConfigFields: ( principalType: PrincipalType, @@ -64,12 +65,14 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { principalType: PrincipalType, principalId: string | Types.ObjectId, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; toggleConfigActive: ( principalType: PrincipalType, principalId: string | Types.ObjectId, isActive: boolean, session?: ClientSession, + options?: { expectEmpty?: boolean }, ) => Promise; } { async function findConfigByPrincipal( @@ -146,13 +149,20 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { overrides: Partial, priority: number, session?: ClientSession, + options?: { expectEmpty?: boolean }, ): Promise { const Config = mongoose.models.Config as Model; - const query = { + const query: FilterQuery = { principalType, principalId: principalId.toString(), }; + if (options?.expectEmpty) { + query.$and = [ + { $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] }, + { $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] }, + ]; + } const update = { $set: { @@ -164,7 +174,7 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { $inc: { configVersion: 1 }, }; - const options = { + const mongoOptions = { upsert: true, new: true, setDefaultsOnInsert: true, @@ -172,11 +182,14 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { }; try { - return await Config.findOneAndUpdate(query, update, options); + return await Config.findOneAndUpdate(query, update, mongoOptions); } catch (err: unknown) { if ((err as { code?: number }).code === 11000) { + if (options?.expectEmpty) { + return null; + } return await Config.findOneAndUpdate( - query, + { principalType, principalId: principalId.toString() }, { $set: update.$set, $inc: update.$inc }, { new: true, ...(session ? { session } : {}) }, ); @@ -289,13 +302,20 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { principalType: PrincipalType, principalId: string | Types.ObjectId, session?: ClientSession, + options?: { expectEmpty?: boolean }, ): Promise { const Config = mongoose.models.Config as Model; - - return await Config.findOneAndDelete({ + const filter: FilterQuery = { principalType, principalId: principalId.toString(), - }).session(session ?? null); + }; + if (options?.expectEmpty) { + filter.$and = [ + { $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] }, + { $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] }, + ]; + } + return await Config.findOneAndDelete(filter).session(session ?? null); } async function toggleConfigActive( @@ -303,10 +323,21 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): { principalId: string | Types.ObjectId, isActive: boolean, session?: ClientSession, + options?: { expectEmpty?: boolean }, ): Promise { const Config = mongoose.models.Config as Model; + const filter: FilterQuery = { + principalType, + principalId: principalId.toString(), + }; + if (options?.expectEmpty) { + filter.$and = [ + { $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] }, + { $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] }, + ]; + } return await Config.findOneAndUpdate( - { principalType, principalId: principalId.toString() }, + filter, { $set: { isActive } }, { new: true, ...(session ? { session } : {}) }, );