diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..725ac8b6bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force LF line endings for shell scripts and git hooks (required for cross-platform compatibility) +.husky/* text eol=lf +*.sh text eol=lf diff --git a/AGENTS.md b/AGENTS.md index ec44607aa7..81362cfc57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,12 @@ The source code for `@librechat/agents` (major backend dependency, same team) is ## Code Style +### Naming and File Organization + +- **Single-word file names** whenever possible (e.g., `permissions.ts`, `capabilities.ts`, `service.ts`). +- When multiple words are needed, prefer grouping related modules under a **single-word directory** rather than using multi-word file names (e.g., `admin/capabilities.ts` not `adminCapabilities.ts`). +- The directory already provides context — `app/service.ts` not `app/appConfigService.ts`. + ### Structure and Clarity - **Never-nesting**: early returns, flat code, minimal indentation. Break complex operations into well-named helpers. diff --git a/api/server/index.js b/api/server/index.js index ba376ab335..0a8a29f3b7 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -141,6 +141,7 @@ const startServer = async () => { /* API Endpoints */ app.use('/api/auth', routes.auth); app.use('/api/admin', routes.adminAuth); + app.use('/api/admin/config', routes.adminConfig); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js index bca3c8f71d..fb5f89b229 100644 --- a/api/server/middleware/config/app.js +++ b/api/server/middleware/config/app.js @@ -4,7 +4,9 @@ const { getAppConfig } = require('~/server/services/Config'); const configMiddleware = async (req, res, next) => { try { const userRole = req.user?.role; - req.config = await getAppConfig({ role: userRole }); + const userId = req.user?.id; + const tenantId = req.user?.tenantId; + req.config = await getAppConfig({ role: userRole, userId, tenantId }); next(); } catch (error) { diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js new file mode 100644 index 0000000000..b9407c6b09 --- /dev/null +++ b/api/server/routes/admin/config.js @@ -0,0 +1,39 @@ +const express = require('express'); +const { createAdminConfigHandlers } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { + hasConfigCapability, + requireCapability, +} = require('~/server/middleware/roles/capabilities'); +const { getAppConfig } = require('~/server/services/Config'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); + +const handlers = createAdminConfigHandlers({ + listAllConfigs: db.listAllConfigs, + findConfigByPrincipal: db.findConfigByPrincipal, + upsertConfig: db.upsertConfig, + patchConfigFields: db.patchConfigFields, + unsetConfigField: db.unsetConfigField, + deleteConfig: db.deleteConfig, + toggleConfigActive: db.toggleConfigActive, + hasConfigCapability, + getAppConfig, +}); + +router.use(requireJwtAuth, requireAdminAccess); + +router.get('/', handlers.listConfigs); +router.get('/base', handlers.getBaseConfig); +router.get('/:principalType/:principalId', handlers.getConfig); +router.put('/:principalType/:principalId', handlers.upsertConfigOverrides); +router.patch('/:principalType/:principalId/fields', handlers.patchConfigField); +router.delete('/:principalType/:principalId/fields', handlers.deleteConfigField); +router.delete('/:principalType/:principalId', handlers.deleteConfigOverrides); +router.patch('/:principalType/:principalId/active', handlers.toggleConfig); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6a48919db3..b1f16d5e3c 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -2,6 +2,7 @@ const accessPermissions = require('./accessPermissions'); const assistants = require('./assistants'); const categories = require('./categories'); const adminAuth = require('./admin/auth'); +const adminConfig = require('./admin/config'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); @@ -31,6 +32,7 @@ module.exports = { mcp, auth, adminAuth, + adminConfig, keys, apiKeys, user, diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index 75a5cbe56d..a63bef2124 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,12 +1,12 @@ const { CacheKeys } = require('librechat-data-provider'); -const { logger, AppService } = require('@librechat/data-schemas'); +const { AppService } = require('@librechat/data-schemas'); +const { createAppConfigService } = require('@librechat/api'); const { loadAndFormatTools } = require('~/server/services/start/tools'); const loadCustomConfig = require('./loadCustomConfig'); const { setCachedTools } = require('./getCachedTools'); const getLogStores = require('~/cache/getLogStores'); const paths = require('~/config/paths'); - -const BASE_CONFIG_KEY = '_BASE_'; +const db = require('~/models'); const loadBaseConfig = async () => { /** @type {TCustomConfig} */ @@ -20,63 +20,14 @@ const loadBaseConfig = async () => { return AppService({ config, paths, systemTools }); }; -/** - * Get the app configuration based on user context - * @param {Object} [options] - * @param {string} [options.role] - User role for role-based config - * @param {boolean} [options.refresh] - Force refresh the cache - * @returns {Promise} - */ -async function getAppConfig(options = {}) { - const { role, refresh } = options; - - const cache = getLogStores(CacheKeys.APP_CONFIG); - const cacheKey = role ? role : BASE_CONFIG_KEY; - - if (!refresh) { - const cached = await cache.get(cacheKey); - if (cached) { - return cached; - } - } - - let baseConfig = await cache.get(BASE_CONFIG_KEY); - if (!baseConfig) { - logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); - baseConfig = await loadBaseConfig(); - - if (!baseConfig) { - throw new Error('Failed to initialize app configuration through AppService.'); - } - - if (baseConfig.availableTools) { - await setCachedTools(baseConfig.availableTools); - } - - await cache.set(BASE_CONFIG_KEY, baseConfig); - } - - // For now, return the base config - // In the future, this is where we'll apply role-based modifications - if (role) { - // TODO: Apply role-based config modifications - // const roleConfig = await applyRoleBasedConfig(baseConfig, role); - // await cache.set(cacheKey, roleConfig); - // return roleConfig; - } - - return baseConfig; -} - -/** - * Clear the app configuration cache - * @returns {Promise} - */ -async function clearAppConfigCache() { - const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cacheKey = CacheKeys.APP_CONFIG; - return await cache.delete(cacheKey); -} +const { getAppConfig, clearAppConfigCache } = createAppConfigService({ + loadBaseConfig, + setCachedTools, + getCache: getLogStores, + cacheKeys: CacheKeys, + getApplicableConfigs: db.getApplicableConfigs, + getUserPrincipals: db.getUserPrincipals, +}); module.exports = { getAppConfig, diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts new file mode 100644 index 0000000000..705c54babc --- /dev/null +++ b/packages/api/src/admin/config.handler.spec.ts @@ -0,0 +1,414 @@ +import { createAdminConfigHandlers } from './config'; + +function mockReq(overrides = {}) { + return { + user: { id: 'u1', role: 'ADMIN', _id: { toString: () => 'u1' } }, + params: {}, + body: {}, + query: {}, + ...overrides, + }; +} + +function mockRes() { + const res = { + statusCode: 200, + body: undefined, + status: jest.fn((code) => { + res.statusCode = code; + return res; + }), + json: jest.fn((data) => { + res.body = data; + return res; + }), + }; + return res; +} + +function createHandlers(overrides = {}) { + const deps = { + listAllConfigs: jest.fn().mockResolvedValue([]), + findConfigByPrincipal: jest.fn().mockResolvedValue(null), + upsertConfig: jest.fn().mockResolvedValue({ + _id: 'c1', + principalType: 'role', + principalId: 'admin', + overrides: {}, + configVersion: 1, + }), + patchConfigFields: jest + .fn() + .mockResolvedValue({ _id: 'c1', overrides: { interface: { endpointsMenu: false } } }), + unsetConfigField: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }), + deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }), + toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }), + hasConfigCapability: jest.fn().mockResolvedValue(true), + + getAppConfig: jest.fn().mockResolvedValue({ interface: { endpointsMenu: true } }), + ...overrides, + }; + const handlers = createAdminConfigHandlers(deps); + return { handlers, deps }; +} + +describe('createAdminConfigHandlers', () => { + describe('getConfig', () => { + it('returns 403 before DB lookup when user lacks READ_CONFIGS', async () => { + const { handlers, deps } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.getConfig(req, res); + + expect(res.statusCode).toBe(403); + expect(deps.findConfigByPrincipal).not.toHaveBeenCalled(); + }); + + it('returns 404 when config does not exist', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ params: { principalType: 'role', principalId: 'nonexistent' } }); + const res = mockRes(); + + await handlers.getConfig(req, res); + + expect(res.statusCode).toBe(404); + }); + + it('returns config when authorized and exists', async () => { + const config = { + _id: 'c1', + principalType: 'role', + principalId: 'admin', + overrides: { x: 1 }, + }; + const { handlers } = createHandlers({ + findConfigByPrincipal: jest.fn().mockResolvedValue(config), + }); + const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } }); + const res = mockRes(); + + await handlers.getConfig(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.config).toEqual(config); + }); + + it('returns 400 for invalid principalType', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ params: { principalType: 'invalid', principalId: 'x' } }); + const res = mockRes(); + + await handlers.getConfig(req, res); + + expect(res.statusCode).toBe(400); + }); + + it('rejects public principalType — not usable for config overrides', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ params: { principalType: 'public', principalId: 'x' } }); + const res = mockRes(); + + await handlers.getConfig(req, res); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('upsertConfigOverrides', () => { + it('returns 201 when creating a new config (configVersion === 1)', async () => { + const { handlers } = createHandlers({ + upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: { interface: { endpointsMenu: false } } }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + }); + + it('returns 200 when updating an existing config (configVersion > 1)', async () => { + const { handlers } = createHandlers({ + upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 5 }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: { interface: { endpointsMenu: false } } }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + }); + + it('returns 400 when overrides is missing', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: {}, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('deleteConfigField', () => { + it('reads fieldPath from query parameter', 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(deps.unsetConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + 'interface.endpointsMenu', + ); + }); + + it('returns 400 when fieldPath query param is missing', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: {}, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain('query parameter'); + }); + + it('rejects unsafe field paths', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: '__proto__.polluted' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('patchConfigField', () => { + it('returns 403 when user lacks capability for section', async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(403); + }); + + it('rejects entries with unsafe field paths (prototype pollution)', async () => { + const { handlers } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: '__proto__.polluted', value: true }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('upsertConfigOverrides — Bug 2 regression', () => { + it('returns 403 for empty overrides when user lacks MANAGE_CONFIGS', async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: {} }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(403); + }); + }); + + // ── Invariant tests: rules that must hold across ALL handlers ────── + + const MUTATION_HANDLERS: Array<{ + name: string; + reqOverrides: Record; + }> = [ + { + name: 'upsertConfigOverrides', + reqOverrides: { + params: { principalType: 'role', principalId: 'admin' }, + body: { overrides: { interface: { endpointsMenu: false } } }, + }, + }, + { + name: 'patchConfigField', + reqOverrides: { + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] }, + }, + }, + { + name: 'deleteConfigField', + reqOverrides: { + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'interface.endpointsMenu' }, + }, + }, + { + name: 'deleteConfigOverrides', + reqOverrides: { + params: { principalType: 'role', principalId: 'admin' }, + }, + }, + { + name: 'toggleConfig', + reqOverrides: { + params: { principalType: 'role', principalId: 'admin' }, + body: { isActive: false }, + }, + }, + ]; + + 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 () => { + const { handlers } = createHandlers(); + const req = mockReq({ ...reqOverrides, user: undefined }); + const res = mockRes(); + + await (handlers as Record Promise>)[name]( + req, + res, + ); + + expect(res.statusCode).toBe(401); + }); + } + }); + + describe('invariant: all mutation handlers return 403 without capability', () => { + for (const { name, reqOverrides } of MUTATION_HANDLERS) { + it(`${name} returns 403 when user lacks capability`, async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq(reqOverrides); + const res = mockRes(); + + await (handlers as Record Promise>)[name]( + req, + res, + ); + + expect(res.statusCode).toBe(403); + }); + } + }); + + describe('invariant: all read handlers return 403 without capability', () => { + const READ_HANDLERS: Array<{ name: string; reqOverrides: Record }> = [ + { name: 'listConfigs', reqOverrides: {} }, + { name: 'getBaseConfig', reqOverrides: {} }, + { + name: 'getConfig', + reqOverrides: { params: { principalType: 'role', principalId: 'admin' } }, + }, + ]; + + for (const { name, reqOverrides } of READ_HANDLERS) { + it(`${name} returns 403 when user lacks capability`, async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq(reqOverrides); + const res = mockRes(); + + await (handlers as Record Promise>)[name]( + req, + res, + ); + + expect(res.statusCode).toBe(403); + }); + } + }); + + describe('invariant: all read handlers return 401 without auth', () => { + const READ_HANDLERS: Array<{ name: string; reqOverrides: Record }> = [ + { name: 'listConfigs', reqOverrides: {} }, + { name: 'getBaseConfig', reqOverrides: {} }, + { + name: 'getConfig', + reqOverrides: { params: { principalType: 'role', principalId: 'admin' } }, + }, + ]; + + for (const { name, reqOverrides } of READ_HANDLERS) { + it(`${name} returns 401 when user is missing`, async () => { + const { handlers } = createHandlers(); + const req = mockReq({ ...reqOverrides, user: undefined }); + const res = mockRes(); + + await (handlers as Record Promise>)[name]( + req, + res, + ); + + expect(res.statusCode).toBe(401); + }); + } + }); + + describe('getBaseConfig', () => { + it('returns 403 when user lacks READ_CONFIGS', async () => { + const { handlers } = createHandlers({ + hasConfigCapability: jest.fn().mockResolvedValue(false), + }); + const req = mockReq(); + const res = mockRes(); + + await handlers.getBaseConfig(req, res); + + expect(res.statusCode).toBe(403); + }); + + it('returns the full AppConfig', async () => { + const { handlers } = createHandlers(); + const req = mockReq(); + const res = mockRes(); + + await handlers.getBaseConfig(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.config).toEqual({ interface: { endpointsMenu: true } }); + }); + }); +}); diff --git a/packages/api/src/admin/config.spec.ts b/packages/api/src/admin/config.spec.ts new file mode 100644 index 0000000000..499cfaa35b --- /dev/null +++ b/packages/api/src/admin/config.spec.ts @@ -0,0 +1,57 @@ +import { isValidFieldPath, getTopLevelSection } from './config'; + +describe('isValidFieldPath', () => { + it('accepts simple dot paths', () => { + expect(isValidFieldPath('interface.endpointsMenu')).toBe(true); + expect(isValidFieldPath('registration.socialLogins')).toBe(true); + expect(isValidFieldPath('a')).toBe(true); + expect(isValidFieldPath('a.b.c.d')).toBe(true); + }); + + it('rejects empty and non-string', () => { + expect(isValidFieldPath('')).toBe(false); + // @ts-expect-error testing invalid input + expect(isValidFieldPath(undefined)).toBe(false); + // @ts-expect-error testing invalid input + expect(isValidFieldPath(null)).toBe(false); + // @ts-expect-error testing invalid input + expect(isValidFieldPath(42)).toBe(false); + }); + + it('rejects __proto__ and dunder-prefixed segments', () => { + expect(isValidFieldPath('__proto__')).toBe(false); + expect(isValidFieldPath('a.__proto__')).toBe(false); + expect(isValidFieldPath('__proto__.polluted')).toBe(false); + expect(isValidFieldPath('a.__proto__.b')).toBe(false); + expect(isValidFieldPath('__defineGetter__')).toBe(false); + expect(isValidFieldPath('a.__lookupSetter__')).toBe(false); + expect(isValidFieldPath('__')).toBe(false); + expect(isValidFieldPath('a.__.b')).toBe(false); + }); + + it('rejects constructor and prototype segments', () => { + expect(isValidFieldPath('constructor')).toBe(false); + expect(isValidFieldPath('a.constructor')).toBe(false); + expect(isValidFieldPath('constructor.a')).toBe(false); + expect(isValidFieldPath('prototype')).toBe(false); + expect(isValidFieldPath('a.prototype')).toBe(false); + expect(isValidFieldPath('prototype.a')).toBe(false); + }); + + it('allows segments containing but not matching reserved words', () => { + expect(isValidFieldPath('constructorName')).toBe(true); + expect(isValidFieldPath('prototypeChain')).toBe(true); + expect(isValidFieldPath('a.myConstructor')).toBe(true); + }); +}); + +describe('getTopLevelSection', () => { + it('returns first segment of a dot path', () => { + expect(getTopLevelSection('interface.endpointsMenu')).toBe('interface'); + expect(getTopLevelSection('registration.socialLogins.github')).toBe('registration'); + }); + + it('returns the whole string when no dots', () => { + expect(getTopLevelSection('interface')).toBe('interface'); + }); +}); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts new file mode 100644 index 0000000000..0a1afd5388 --- /dev/null +++ b/packages/api/src/admin/config.ts @@ -0,0 +1,509 @@ +import { logger } from '@librechat/data-schemas'; +import { PrincipalType, PrincipalModel } 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'; +import type { Response } from 'express'; +import type { CapabilityUser } from '~/middleware/capabilities'; +import type { ServerRequest } from '~/types/http'; + +const UNSAFE_SEGMENTS = /(?:^|\.)(__[\w]*|constructor|prototype)(?:\.|$)/; +const MAX_PATCH_ENTRIES = 100; +const DEFAULT_PRIORITY = 10; + +export function isValidFieldPath(path: string): boolean { + return ( + typeof path === 'string' && + path.length > 0 && + !path.startsWith('.') && + !path.endsWith('.') && + !path.includes('..') && + !UNSAFE_SEGMENTS.test(path) + ); +} + +export function getTopLevelSection(fieldPath: string): string { + return fieldPath.split('.')[0]; +} + +export interface AdminConfigDeps { + listAllConfigs: (filter?: { isActive?: boolean }, session?: ClientSession) => Promise; + findConfigByPrincipal: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + options?: { includeInactive?: boolean }, + session?: ClientSession, + ) => Promise; + upsertConfig: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + overrides: Partial, + priority: number, + session?: ClientSession, + ) => Promise; + patchConfigFields: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + fields: Record, + priority: number, + session?: ClientSession, + ) => Promise; + unsetConfigField: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + fieldPath: string, + session?: ClientSession, + ) => Promise; + deleteConfig: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + session?: ClientSession, + ) => Promise; + toggleConfigActive: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + isActive: boolean, + session?: ClientSession, + ) => Promise; + hasConfigCapability: ( + user: CapabilityUser, + section: ConfigSection | null, + verb?: 'manage' | 'read', + ) => Promise; + getAppConfig?: (options?: { + role?: string; + userId?: string; + tenantId?: string; + }) => Promise; +} + +// ── Validation helpers ─────────────────────────────────────────────── + +const CONFIG_PRINCIPAL_TYPES = new Set([ + PrincipalType.USER, + PrincipalType.GROUP, + PrincipalType.ROLE, +]); + +function validatePrincipalType(value: string): value is PrincipalType { + return CONFIG_PRINCIPAL_TYPES.has(value as PrincipalType); +} + +function principalModel(type: PrincipalType): PrincipalModel { + switch (type) { + case PrincipalType.USER: + return PrincipalModel.USER; + case PrincipalType.GROUP: + return PrincipalModel.GROUP; + case PrincipalType.ROLE: + return PrincipalModel.ROLE; + case PrincipalType.PUBLIC: + return PrincipalModel.ROLE; + default: { + const _exhaustive: never = type; + logger.warn(`[adminConfig] Unmapped PrincipalType: ${String(_exhaustive)}`); + return PrincipalModel.ROLE; + } + } +} + +function getCapabilityUser(req: ServerRequest): CapabilityUser | null { + if (!req.user) { + return null; + } + return { + id: req.user.id ?? req.user._id?.toString() ?? '', + role: req.user.role ?? '', + tenantId: (req.user as { tenantId?: string }).tenantId, + }; +} + +// ── Handler factory ────────────────────────────────────────────────── + +export function createAdminConfigHandlers(deps: AdminConfigDeps) { + const { + listAllConfigs, + findConfigByPrincipal, + upsertConfig, + patchConfigFields, + unsetConfigField, + deleteConfig, + toggleConfigActive, + hasConfigCapability, + getAppConfig, + } = deps; + + /** + * GET / — List all active config overrides. + */ + async function listConfigs(req: ServerRequest, res: Response) { + try { + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'read'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const configs = await listAllConfigs(); + return res.status(200).json({ configs }); + } catch (error) { + logger.error('[adminConfig] listConfigs error:', error); + return res.status(500).json({ error: 'Failed to list configs' }); + } + } + + /** + * GET /base — Return the raw AppConfig (YAML + DB base merged). + * This is the full config structure admins can edit, NOT the startup payload. + */ + async function getBaseConfig(req: ServerRequest, res: Response) { + try { + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'read'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + if (!getAppConfig) { + return res.status(501).json({ error: 'Base config endpoint not configured' }); + } + + const appConfig = await getAppConfig(); + return res.status(200).json({ config: appConfig }); + } catch (error) { + logger.error('[adminConfig] getBaseConfig error:', error); + return res.status(500).json({ error: 'Failed to get base config' }); + } + } + + /** + * GET /:principalType/:principalId — Get config for a specific principal. + */ + async function getConfig(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'read'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const config = await findConfigByPrincipal(principalType, principalId, { + includeInactive: true, + }); + if (!config) { + return res.status(404).json({ error: 'Config not found' }); + } + + return res.status(200).json({ config }); + } catch (error) { + logger.error('[adminConfig] getConfig error:', error); + return res.status(500).json({ error: 'Failed to get config' }); + } + } + + /** + * PUT /:principalType/:principalId — Replace entire overrides for a principal. + */ + async function upsertConfigOverrides(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const { overrides, priority } = req.body as { + overrides?: Partial; + priority?: number; + }; + + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { + return res.status(400).json({ error: 'overrides must be a plain object' }); + } + + if (priority != null && (typeof priority !== 'number' || priority < 0)) { + return res.status(400).json({ error: 'priority must be a non-negative number' }); + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'manage'))) { + 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}`, + }); + } + } + + const config = await upsertConfig( + principalType, + principalId, + principalModel(principalType), + overrides, + priority ?? DEFAULT_PRIORITY, + ); + + return res.status(config?.configVersion === 1 ? 201 : 200).json({ config }); + } catch (error) { + logger.error('[adminConfig] upsertConfigOverrides error:', error); + return res.status(500).json({ error: 'Failed to upsert config' }); + } + } + + /** + * PATCH /:principalType/:principalId/fields — Set individual fields via dot-paths. + */ + async function patchConfigField(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const { entries, priority } = req.body as { + entries?: Array<{ fieldPath: string; value: unknown }>; + priority?: number; + }; + + if (priority != null && (typeof priority !== 'number' || priority < 0)) { + return res.status(400).json({ error: 'priority must be a non-negative number' }); + } + + if (!Array.isArray(entries) || entries.length === 0) { + return res.status(400).json({ error: 'entries array is required and must not be empty' }); + } + + if (entries.length > MAX_PATCH_ENTRIES) { + return res + .status(400) + .json({ error: `entries array exceeds maximum of ${MAX_PATCH_ENTRIES}` }); + } + + for (const entry of entries) { + if (!isValidFieldPath(entry.fieldPath)) { + return res + .status(400) + .json({ error: `Invalid or unsafe field path: ${entry.fieldPath}` }); + } + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'manage'))) { + const sections = [...new Set(entries.map((e) => getTopLevelSection(e.fieldPath)))]; + const allowed = await Promise.all( + sections.map((s) => hasConfigCapability(user, s as ConfigSection, 'manage')), + ); + const denied = sections.find((_, i) => !allowed[i]); + if (denied) { + return res.status(403).json({ + error: `Insufficient permissions for config section: ${denied}`, + }); + } + } + + const seen = new Set(); + const fields: Record = {}; + for (const entry of entries) { + if (seen.has(entry.fieldPath)) { + return res.status(400).json({ error: `Duplicate fieldPath: ${entry.fieldPath}` }); + } + seen.add(entry.fieldPath); + fields[entry.fieldPath] = entry.value; + } + + const existing = + priority == null + ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) + : null; + + const config = await patchConfigFields( + principalType, + principalId, + principalModel(principalType), + fields, + priority ?? existing?.priority ?? DEFAULT_PRIORITY, + ); + + return res.status(200).json({ config }); + } catch (error) { + logger.error('[adminConfig] patchConfigField error:', error); + return res.status(500).json({ error: 'Failed to patch config fields' }); + } + } + + /** + * DELETE /:principalType/:principalId/fields?fieldPath=dotted.path + */ + async function deleteConfigField(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const fieldPath = req.query.fieldPath as string | undefined; + + if (!fieldPath || typeof fieldPath !== 'string') { + return res.status(400).json({ error: 'fieldPath query parameter is required' }); + } + + if (!isValidFieldPath(fieldPath)) { + return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` }); + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const section = getTopLevelSection(fieldPath); + if (!(await hasConfigCapability(user, section as ConfigSection, 'manage'))) { + return res.status(403).json({ + error: `Insufficient permissions for config section: ${section}`, + }); + } + + const config = await unsetConfigField(principalType, principalId, fieldPath); + if (!config) { + return res.status(404).json({ error: 'Config not found' }); + } + + return res.status(200).json({ config }); + } catch (error) { + logger.error('[adminConfig] deleteConfigField error:', error); + return res.status(500).json({ error: 'Failed to delete config field' }); + } + } + + /** + * DELETE /:principalType/:principalId — Delete an entire config override. + */ + async function deleteConfigOverrides(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'manage'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const config = await deleteConfig(principalType, principalId); + if (!config) { + return res.status(404).json({ error: 'Config not found' }); + } + + return res.status(200).json({ success: true }); + } catch (error) { + logger.error('[adminConfig] deleteConfigOverrides error:', error); + return res.status(500).json({ error: 'Failed to delete config' }); + } + } + + /** + * PATCH /:principalType/:principalId/active — Toggle isActive. + */ + async function toggleConfig(req: ServerRequest, res: Response) { + try { + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + if (!validatePrincipalType(principalType)) { + return res.status(400).json({ error: `Invalid principalType: ${principalType}` }); + } + + const { isActive } = req.body as { isActive?: boolean }; + if (typeof isActive !== 'boolean') { + return res.status(400).json({ error: 'isActive boolean is required' }); + } + + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!(await hasConfigCapability(user, null, 'manage'))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const config = await toggleConfigActive(principalType, principalId, isActive); + if (!config) { + return res.status(404).json({ error: 'Config not found' }); + } + + return res.status(200).json({ config }); + } catch (error) { + logger.error('[adminConfig] toggleConfig error:', error); + return res.status(500).json({ error: 'Failed to toggle config' }); + } + } + + return { + listConfigs, + getBaseConfig, + getConfig, + upsertConfigOverrides, + patchConfigField, + deleteConfigField, + deleteConfigOverrides, + toggleConfig, + }; +} diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts new file mode 100644 index 0000000000..bf48ce7345 --- /dev/null +++ b/packages/api/src/admin/index.ts @@ -0,0 +1,2 @@ +export { createAdminConfigHandlers } from './config'; +export type { AdminConfigDeps } from './config'; diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index b95193e943..7acb75e09d 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,3 +1,4 @@ +export * from './service'; export * from './config'; export * from './permissions'; export * from './cdn'; diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts new file mode 100644 index 0000000000..2dfba09e25 --- /dev/null +++ b/packages/api/src/app/service.spec.ts @@ -0,0 +1,244 @@ +import { createAppConfigService } from './service'; + +function createMockCache() { + const store = new Map(); + return { + get: jest.fn((key) => Promise.resolve(store.get(key))), + set: jest.fn((key, value) => { + store.set(key, value); + return Promise.resolve(undefined); + }), + delete: jest.fn((key) => { + store.delete(key); + return Promise.resolve(true); + }), + _store: store, + }; +} + +function createDeps(overrides = {}) { + const cache = createMockCache(); + const baseConfig = { interface: { endpointsMenu: true }, endpoints: ['openAI'] }; + + return { + loadBaseConfig: jest.fn().mockResolvedValue(baseConfig), + setCachedTools: jest.fn().mockResolvedValue(undefined), + getCache: jest.fn().mockReturnValue(cache), + cacheKeys: { APP_CONFIG: 'app_config' }, + getApplicableConfigs: jest.fn().mockResolvedValue([]), + getUserPrincipals: jest.fn().mockResolvedValue([ + { principalType: 'role', principalId: 'USER' }, + { principalType: 'user', principalId: 'uid1' }, + ]), + _cache: cache, + _baseConfig: baseConfig, + ...overrides, + }; +} + +describe('createAppConfigService', () => { + describe('getAppConfig', () => { + it('loads base config on first call', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + const config = await getAppConfig(); + + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); + expect(config).toEqual(deps._baseConfig); + }); + + it('caches base config — does not reload on second call', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig(); + await getAppConfig(); + + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); + }); + + it('reloads base config when refresh is true', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig(); + await getAppConfig({ refresh: true }); + + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(2); + }); + + it('queries DB for applicable configs', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'ADMIN' }); + + expect(deps.getApplicableConfigs).toHaveBeenCalled(); + }); + + it('caches empty result — does not re-query DB on second call', async () => { + const deps = createDeps({ getApplicableConfigs: jest.fn().mockResolvedValue([]) }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'USER' }); + await getAppConfig({ role: 'USER' }); + + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); + }); + + it('merges DB configs when found', async () => { + const deps = createDeps({ + getApplicableConfigs: jest + .fn() + .mockResolvedValue([ + { priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true }, + ]), + }); + const { getAppConfig } = createAppConfigService(deps); + + const config = await getAppConfig({ role: 'ADMIN' }); + + expect(config.interface.endpointsMenu).toBe(false); + expect(config.endpoints).toEqual(['openAI']); + }); + + it('caches merged result with TTL', async () => { + const deps = createDeps({ + getApplicableConfigs: jest + .fn() + .mockResolvedValue([{ priority: 10, overrides: { x: 1 }, isActive: true }]), + }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'ADMIN' }); + await getAppConfig({ role: 'ADMIN' }); + + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); + }); + + it('uses separate cache keys per userId (no cross-user contamination)', async () => { + const deps = createDeps({ + getApplicableConfigs: jest + .fn() + .mockResolvedValue([ + { priority: 100, overrides: { x: 'user-specific' }, isActive: true }, + ]), + }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ userId: 'uid1' }); + await getAppConfig({ userId: 'uid2' }); + + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2); + }); + + it('userId without role gets its own cache key', async () => { + const deps = createDeps({ + getApplicableConfigs: jest + .fn() + .mockResolvedValue([{ priority: 100, overrides: { y: 1 }, isActive: true }]), + }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ userId: 'uid1' }); + + const cachedKeys = [...deps._cache._store.keys()]; + const overrideKey = cachedKeys.find((k) => k.startsWith('_OVERRIDE_:')); + expect(overrideKey).toBe('_OVERRIDE_:__default__:uid1'); + }); + + it('tenantId is included in cache key to prevent cross-tenant contamination', async () => { + const deps = createDeps({ + getApplicableConfigs: jest + .fn() + .mockResolvedValue([{ priority: 10, overrides: { x: 1 }, isActive: true }]), + }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-a' }); + await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-b' }); + + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2); + }); + + it('base-only empty result does not block subsequent scoped queries with results', async () => { + const mockGetConfigs = jest.fn().mockResolvedValue([]); + const deps = createDeps({ getApplicableConfigs: mockGetConfigs }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig(); + + mockGetConfigs.mockResolvedValueOnce([ + { priority: 10, overrides: { restricted: true }, isActive: true }, + ]); + const config = await getAppConfig({ role: 'ADMIN' }); + + expect(mockGetConfigs).toHaveBeenCalledTimes(2); + expect((config as Record).restricted).toBe(true); + }); + + it('does not short-circuit other users when one user has no overrides', async () => { + const mockGetConfigs = jest.fn().mockResolvedValue([]); + const deps = createDeps({ getApplicableConfigs: mockGetConfigs }); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'USER' }); + expect(mockGetConfigs).toHaveBeenCalledTimes(1); + + mockGetConfigs.mockResolvedValueOnce([ + { priority: 10, overrides: { x: 'admin-only' }, isActive: true }, + ]); + const config = await getAppConfig({ role: 'ADMIN' }); + + expect(mockGetConfigs).toHaveBeenCalledTimes(2); + expect((config as Record).x).toBe('admin-only'); + }); + + it('falls back to base config on getApplicableConfigs error', async () => { + const deps = createDeps({ + getApplicableConfigs: jest.fn().mockRejectedValue(new Error('DB down')), + }); + const { getAppConfig } = createAppConfigService(deps); + + const config = await getAppConfig({ role: 'ADMIN' }); + + expect(config).toEqual(deps._baseConfig); + }); + + it('calls getUserPrincipals when userId is provided', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'USER', userId: 'uid1' }); + + expect(deps.getUserPrincipals).toHaveBeenCalledWith({ + userId: 'uid1', + role: 'USER', + }); + }); + + it('does not call getUserPrincipals when only role is provided', async () => { + const deps = createDeps(); + const { getAppConfig } = createAppConfigService(deps); + + await getAppConfig({ role: 'ADMIN' }); + + expect(deps.getUserPrincipals).not.toHaveBeenCalled(); + }); + }); + + describe('clearAppConfigCache', () => { + it('clears base config so it reloads on next call', async () => { + const deps = createDeps(); + const { getAppConfig, clearAppConfigCache } = createAppConfigService(deps); + + await getAppConfig(); + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); + + await clearAppConfigCache(); + await getAppConfig(); + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts new file mode 100644 index 0000000000..b7826e40ee --- /dev/null +++ b/packages/api/src/app/service.ts @@ -0,0 +1,155 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { logger, mergeConfigOverrides, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas'; +import type { Types } from 'mongoose'; +import type { AppConfig, IConfig } from '@librechat/data-schemas'; + +const BASE_CONFIG_KEY = '_BASE_'; + +const DEFAULT_OVERRIDE_CACHE_TTL = 60_000; + +// ── Types ──────────────────────────────────────────────────────────── + +interface CacheStore { + get: (key: string) => Promise; + set: (key: string, value: unknown, ttl?: number) => Promise; + delete: (key: string) => Promise; +} + +export interface AppConfigServiceDeps { + /** Load the base AppConfig from YAML + AppService processing. */ + loadBaseConfig: () => Promise; + /** Cache tools after base config is loaded. */ + setCachedTools: (tools: Record) => Promise; + /** Get a cache store by key. */ + getCache: (key: string) => CacheStore; + /** The CacheKeys constants from librechat-data-provider. */ + cacheKeys: { APP_CONFIG: string }; + /** Fetch applicable DB config overrides for a set of principals. */ + getApplicableConfigs: ( + principals?: Array<{ principalType: string; principalId?: string | Types.ObjectId }>, + ) => Promise; + /** Resolve full principal list (user + role + groups) from userId/role. */ + getUserPrincipals: (params: { + userId: string | Types.ObjectId; + role?: string | null; + }) => Promise>; + /** TTL in ms for per-user/role merged config caches. Defaults to 60 000. */ + overrideCacheTtl?: number; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function overrideCacheKey(role?: string, userId?: string, tenantId?: string): string { + const tenant = tenantId || '__default__'; + if (userId && role) { + return `_OVERRIDE_:${tenant}:${role}:${userId}`; + } + if (userId) { + return `_OVERRIDE_:${tenant}:${userId}`; + } + if (role) { + return `_OVERRIDE_:${tenant}:${role}`; + } + return `_OVERRIDE_:${tenant}:${BASE_CONFIG_PRINCIPAL_ID}`; +} + +// ── Service factory ────────────────────────────────────────────────── + +export function createAppConfigService(deps: AppConfigServiceDeps) { + const { + loadBaseConfig, + setCachedTools, + getCache, + cacheKeys, + getApplicableConfigs, + getUserPrincipals, + overrideCacheTtl = DEFAULT_OVERRIDE_CACHE_TTL, + } = deps; + + const cache = getCache(cacheKeys.APP_CONFIG); + + async function buildPrincipals( + role?: string, + userId?: string, + ): Promise> { + if (userId) { + return getUserPrincipals({ userId, role }); + } + const principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }> = []; + if (role) { + principals.push({ principalType: PrincipalType.ROLE, principalId: role }); + } + return principals; + } + + /** + * Get the app configuration, optionally merged with DB overrides for the given principal. + * + * The base config (from YAML + AppService) is cached indefinitely. Per-principal merged + * configs are cached with a short TTL (`overrideCacheTtl`, default 60s). On cache miss, + * `getApplicableConfigs` queries the DB for matching overrides and merges them by priority. + */ + async function getAppConfig( + options: { role?: string; userId?: string; tenantId?: string; refresh?: boolean } = {}, + ): Promise { + const { role, userId, tenantId, refresh } = options; + + let baseConfig = (await cache.get(BASE_CONFIG_KEY)) as AppConfig | undefined; + if (!baseConfig || refresh) { + logger.info('[getAppConfig] Loading base configuration...'); + baseConfig = await loadBaseConfig(); + + if (!baseConfig) { + throw new Error('Failed to initialize app configuration through AppService.'); + } + + if (baseConfig.availableTools) { + await setCachedTools(baseConfig.availableTools); + } + + await cache.set(BASE_CONFIG_KEY, baseConfig); + } + + const cacheKey = overrideCacheKey(role, userId, tenantId); + if (!refresh) { + const cachedMerged = (await cache.get(cacheKey)) as AppConfig | undefined; + if (cachedMerged) { + return cachedMerged; + } + } + + try { + const principals = await buildPrincipals(role, userId); + const configs = await getApplicableConfigs(principals); + + if (configs.length === 0) { + await cache.set(cacheKey, baseConfig, overrideCacheTtl); + return baseConfig; + } + + const merged = mergeConfigOverrides(baseConfig, configs); + await cache.set(cacheKey, merged, overrideCacheTtl); + return merged; + } catch (error) { + logger.error('[getAppConfig] Error resolving config overrides, falling back to base:', error); + return baseConfig; + } + } + + /** + * Clear the base config cache. Per-user/role override caches (`_OVERRIDE_:*`) + * are NOT flushed — they expire naturally via `overrideCacheTtl`. After calling this, + * the base config will be reloaded from YAML on the next `getAppConfig` call, but + * users with cached overrides may see stale merged configs for up to `overrideCacheTtl` ms. + */ + async function clearAppConfigCache(): Promise { + await cache.delete(BASE_CONFIG_KEY); + } + + return { + getAppConfig, + clearAppConfigCache, + }; +} + +export type AppConfigService = ReturnType; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index ef32e7b6b0..5ccf6b0124 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,6 @@ export * from './app'; +/* Admin */ +export * from './admin'; export * from './cdn'; /* Auth */ export * from './auth'; diff --git a/packages/api/src/middleware/capabilities.ts b/packages/api/src/middleware/capabilities.ts index c06a90ac8e..28d3a0f76e 100644 --- a/packages/api/src/middleware/capabilities.ts +++ b/packages/api/src/middleware/capabilities.ts @@ -26,7 +26,7 @@ interface CapabilityDeps { }) => Promise; } -interface CapabilityUser { +export interface CapabilityUser { id: string; role: string; tenantId?: string; @@ -48,7 +48,7 @@ export type RequireCapabilityFn = ( export type HasConfigCapabilityFn = ( user: CapabilityUser, - section: ConfigSection, + section: ConfigSection | null, verb?: 'manage' | 'read', ) => Promise; @@ -138,11 +138,14 @@ export function generateCapabilityCheck(deps: CapabilityDeps): { */ async function hasConfigCapability( user: CapabilityUser, - section: ConfigSection, + section: ConfigSection | null, verb: 'manage' | 'read' = 'manage', ): Promise { const broadCap = verb === 'manage' ? SystemCapabilities.MANAGE_CONFIGS : SystemCapabilities.READ_CONFIGS; + if (section == null) { + return hasCapability(user, broadCap); + } if (await hasCapability(user, broadCap)) { return true; } diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index a66f4eec4e..1e0c76f37f 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.401", + "version": "0.8.403", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", @@ -18,6 +18,9 @@ "types": "./dist/types/react-query/index.d.ts" } }, + "files": [ + "dist" + ], "scripts": { "clean": "rimraf dist", "build": "npm run clean && rollup -c --silent --bundleConfigAsCjs", diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 0376804ad4..0124552002 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,16 +1,22 @@ { "name": "@librechat/data-schemas", - "version": "0.0.40", + "version": "0.0.46", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", "module": "dist/index.es.js", "types": "./dist/types/index.d.ts", + "sideEffects": false, "exports": { ".": { "import": "./dist/index.es.js", "require": "./dist/index.cjs", "types": "./dist/types/index.d.ts" + }, + "./capabilities": { + "import": "./dist/admin/capabilities.es.js", + "require": "./dist/admin/capabilities.cjs", + "types": "./dist/types/admin/capabilities.d.ts" } }, "files": [ diff --git a/packages/data-schemas/rollup.config.js b/packages/data-schemas/rollup.config.js index d58331feee..703630e121 100644 --- a/packages/data-schemas/rollup.config.js +++ b/packages/data-schemas/rollup.config.js @@ -8,14 +8,20 @@ export default { input: 'src/index.ts', output: [ { - file: 'dist/index.es.js', + dir: 'dist', format: 'es', sourcemap: true, + preserveModules: true, + preserveModulesRoot: 'src', + entryFileNames: '[name].es.js', }, { - file: 'dist/index.cjs', + dir: 'dist', format: 'cjs', sourcemap: true, + preserveModules: true, + preserveModulesRoot: 'src', + entryFileNames: '[name].cjs', }, ], plugins: [ diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts new file mode 100644 index 0000000000..447db235a2 --- /dev/null +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -0,0 +1,199 @@ +import { ResourceType } from 'librechat-data-provider'; +import type { + BaseSystemCapability, + SystemCapability, + ConfigSection, + CapabilityCategory, +} from '~/types/admin'; + +// --------------------------------------------------------------------------- +// System Capabilities +// --------------------------------------------------------------------------- + +/** + * The canonical set of base system capabilities. + * + * These are used by the admin panel and LibreChat API to gate access to + * admin features. Config-section-derived capabilities (e.g. + * `manage:configs:endpoints`) are built on top of these where the + * configSchema is available. + */ +export const SystemCapabilities = { + ACCESS_ADMIN: 'access:admin', + READ_USERS: 'read:users', + MANAGE_USERS: 'manage:users', + READ_GROUPS: 'read:groups', + MANAGE_GROUPS: 'manage:groups', + READ_ROLES: 'read:roles', + MANAGE_ROLES: 'manage:roles', + READ_CONFIGS: 'read:configs', + MANAGE_CONFIGS: 'manage:configs', + ASSIGN_CONFIGS: 'assign:configs', + READ_USAGE: 'read:usage', + READ_AGENTS: 'read:agents', + MANAGE_AGENTS: 'manage:agents', + MANAGE_MCP_SERVERS: 'manage:mcpservers', + READ_PROMPTS: 'read:prompts', + MANAGE_PROMPTS: 'manage:prompts', + /** Reserved — not yet enforced by any middleware. */ + READ_ASSISTANTS: 'read:assistants', + MANAGE_ASSISTANTS: 'manage:assistants', +} as const; + +/** + * Capabilities that are implied by holding a broader capability. + * e.g. `MANAGE_USERS` implies `READ_USERS`. + */ +export const CapabilityImplications: Partial> = + { + [SystemCapabilities.MANAGE_USERS]: [SystemCapabilities.READ_USERS], + [SystemCapabilities.MANAGE_GROUPS]: [SystemCapabilities.READ_GROUPS], + [SystemCapabilities.MANAGE_ROLES]: [SystemCapabilities.READ_ROLES], + [SystemCapabilities.MANAGE_CONFIGS]: [SystemCapabilities.READ_CONFIGS], + [SystemCapabilities.MANAGE_AGENTS]: [SystemCapabilities.READ_AGENTS], + [SystemCapabilities.MANAGE_PROMPTS]: [SystemCapabilities.READ_PROMPTS], + [SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS], + }; + +// --------------------------------------------------------------------------- +// Capability utility functions +// --------------------------------------------------------------------------- + +/** Reverse map: for a given read capability, which manage capabilities imply it? */ +const impliedByMap: Record = {}; +for (const [manage, reads] of Object.entries(CapabilityImplications)) { + for (const read of reads as string[]) { + if (!impliedByMap[read]) { + impliedByMap[read] = []; + } + impliedByMap[read].push(manage); + } +} + +/** + * Check whether a set of held capabilities satisfies a required capability, + * accounting for the manage→read implication hierarchy. + */ +export function hasImpliedCapability(held: string[], required: string): boolean { + if (held.includes(required)) { + return true; + } + const impliers = impliedByMap[required]; + if (impliers) { + for (const cap of impliers) { + if (held.includes(cap)) { + return true; + } + } + } + return false; +} + +/** + * Given a set of directly-held capabilities, compute the full set including + * all implied capabilities. + */ +export function expandImplications(directCaps: string[]): string[] { + const expanded = new Set(directCaps); + for (const cap of directCaps) { + const implied = CapabilityImplications[cap as BaseSystemCapability]; + if (implied) { + for (const imp of implied) { + expanded.add(imp); + } + } + } + return Array.from(expanded); +} + +// --------------------------------------------------------------------------- +// Resource & config capability mappings +// --------------------------------------------------------------------------- + +/** + * Maps each ACL ResourceType to the SystemCapability that grants + * unrestricted management access. Typed as `Record` + * so adding a new ResourceType variant causes a compile error until a + * capability is assigned here. + */ +export const ResourceCapabilityMap: Record = { + [ResourceType.AGENT]: SystemCapabilities.MANAGE_AGENTS, + [ResourceType.PROMPTGROUP]: SystemCapabilities.MANAGE_PROMPTS, + [ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS, + [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, +}; + +/** + * Derives a section-level config management capability from a configSchema key. + * @example configCapability('endpoints') → 'manage:configs:endpoints' + * + * TODO: Section-level config capabilities are scaffolded but not yet active. + * To activate delegated config management: + * 1. Expose POST/DELETE /api/admin/grants endpoints (wiring grantCapability/revokeCapability) + * 2. Seed section-specific grants for delegated admin roles via those endpoints + * 3. Guard config write handlers with hasConfigCapability(user, section) + */ +export function configCapability(section: ConfigSection): `manage:configs:${ConfigSection}` { + return `manage:configs:${section}`; +} + +/** + * Derives a section-level config read capability from a configSchema key. + * @example readConfigCapability('endpoints') → 'read:configs:endpoints' + */ +export function readConfigCapability(section: ConfigSection): `read:configs:${ConfigSection}` { + return `read:configs:${section}`; +} + +// --------------------------------------------------------------------------- +// Reserved principal IDs +// --------------------------------------------------------------------------- + +/** Reserved principalId for the DB base config (overrides YAML defaults). */ +export const BASE_CONFIG_PRINCIPAL_ID = '__base__'; + +/** Pre-defined UI categories for grouping capabilities in the admin panel. */ +export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [ + { + key: 'users', + labelKey: 'com_cap_cat_users', + capabilities: [SystemCapabilities.MANAGE_USERS, SystemCapabilities.READ_USERS], + }, + { + key: 'groups', + labelKey: 'com_cap_cat_groups', + capabilities: [SystemCapabilities.MANAGE_GROUPS, SystemCapabilities.READ_GROUPS], + }, + { + key: 'roles', + labelKey: 'com_cap_cat_roles', + capabilities: [SystemCapabilities.MANAGE_ROLES, SystemCapabilities.READ_ROLES], + }, + { + key: 'config', + labelKey: 'com_cap_cat_config', + capabilities: [ + SystemCapabilities.MANAGE_CONFIGS, + SystemCapabilities.READ_CONFIGS, + SystemCapabilities.ASSIGN_CONFIGS, + ], + }, + { + key: 'content', + labelKey: 'com_cap_cat_content', + capabilities: [ + SystemCapabilities.MANAGE_AGENTS, + SystemCapabilities.READ_AGENTS, + SystemCapabilities.MANAGE_PROMPTS, + SystemCapabilities.READ_PROMPTS, + SystemCapabilities.MANAGE_ASSISTANTS, + SystemCapabilities.READ_ASSISTANTS, + SystemCapabilities.MANAGE_MCP_SERVERS, + ], + }, + { + key: 'system', + labelKey: 'com_cap_cat_system', + capabilities: [SystemCapabilities.ACCESS_ADMIN, SystemCapabilities.READ_USAGE], + }, +]; diff --git a/packages/data-schemas/src/admin/index.ts b/packages/data-schemas/src/admin/index.ts new file mode 100644 index 0000000000..8d43daada6 --- /dev/null +++ b/packages/data-schemas/src/admin/index.ts @@ -0,0 +1 @@ +export * from './capabilities'; diff --git a/packages/data-schemas/src/app/index.ts b/packages/data-schemas/src/app/index.ts index 77cb799f8c..b07a36acd0 100644 --- a/packages/data-schemas/src/app/index.ts +++ b/packages/data-schemas/src/app/index.ts @@ -5,3 +5,4 @@ export * from './specs'; export * from './turnstile'; export * from './vertex'; export * from './web'; +export * from './resolution'; diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts new file mode 100644 index 0000000000..12f8985a48 --- /dev/null +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -0,0 +1,108 @@ +import { mergeConfigOverrides } from './resolution'; +import type { AppConfig, IConfig } from '~/types'; + +function fakeConfig(overrides: Record, priority: number): IConfig { + return { + _id: 'fake', + principalType: 'role', + principalId: 'test', + principalModel: 'Role', + priority, + overrides, + isActive: true, + configVersion: 1, + } as unknown as IConfig; +} + +const baseConfig = { + interface: { endpointsMenu: true, sidePanel: true }, + registration: { enabled: true }, + endpoints: ['openAI'], +} as unknown as AppConfig; + +describe('mergeConfigOverrides', () => { + it('returns base config when configs array is empty', () => { + expect(mergeConfigOverrides(baseConfig, [])).toBe(baseConfig); + }); + + it('returns base config when configs is null/undefined', () => { + expect(mergeConfigOverrides(baseConfig, null as unknown as IConfig[])).toBe(baseConfig); + expect(mergeConfigOverrides(baseConfig, undefined as unknown as IConfig[])).toBe(baseConfig); + }); + + it('deep merges a single override into base', () => { + const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + const iface = result.interface as Record; + expect(iface.endpointsMenu).toBe(false); + expect(iface.sidePanel).toBe(true); + }); + + it('sorts by priority — higher priority wins', () => { + const configs = [ + fakeConfig({ registration: { enabled: false } }, 100), + fakeConfig({ registration: { enabled: true, custom: 'yes' } }, 10), + ]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + const reg = result.registration as Record; + expect(reg.enabled).toBe(false); + expect(reg.custom).toBe('yes'); + }); + + it('replaces arrays instead of concatenating', () => { + const configs = [fakeConfig({ endpoints: ['anthropic', 'google'] }, 10)]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + expect(result.endpoints).toEqual(['anthropic', 'google']); + }); + + it('does not mutate the base config', () => { + const original = JSON.parse(JSON.stringify(baseConfig)); + const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)]; + mergeConfigOverrides(baseConfig, configs); + expect(baseConfig).toEqual(original); + }); + + it('handles null override values', () => { + const configs = [fakeConfig({ interface: { endpointsMenu: null } }, 10)]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + const iface = result.interface as Record; + expect(iface.endpointsMenu).toBeNull(); + }); + + it('skips configs with no overrides object', () => { + const configs = [fakeConfig(undefined as unknown as Record, 10)]; + const result = mergeConfigOverrides(baseConfig, configs); + expect(result).toEqual(baseConfig); + }); + + it('strips __proto__, constructor, and prototype keys from overrides', () => { + const configs = [ + fakeConfig( + { + __proto__: { polluted: true }, + constructor: { bad: true }, + prototype: { evil: true }, + safe: 'ok', + }, + 10, + ), + ]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + expect(result.safe).toBe('ok'); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(result, 'constructor')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(result, 'prototype')).toBe(false); + }); + + it('merges three priority levels in order', () => { + const configs = [ + fakeConfig({ interface: { endpointsMenu: false } }, 0), + fakeConfig({ interface: { endpointsMenu: true, sidePanel: false } }, 10), + fakeConfig({ interface: { sidePanel: true } }, 100), + ]; + const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; + const iface = result.interface as Record; + expect(iface.endpointsMenu).toBe(true); + expect(iface.sidePanel).toBe(true); + }); +}); diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts new file mode 100644 index 0000000000..ad1c1fbff0 --- /dev/null +++ b/packages/data-schemas/src/app/resolution.ts @@ -0,0 +1,54 @@ +import type { AppConfig, IConfig } from '~/types'; + +type AnyObject = { [key: string]: unknown }; + +const MAX_MERGE_DEPTH = 10; +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function deepMerge(target: T, source: AnyObject, depth = 0): T { + const result = { ...target } as AnyObject; + for (const key of Object.keys(source)) { + if (UNSAFE_KEYS.has(key)) { + continue; + } + const sourceVal = source[key]; + const targetVal = result[key]; + if ( + depth < MAX_MERGE_DEPTH && + sourceVal != null && + typeof sourceVal === 'object' && + !Array.isArray(sourceVal) && + targetVal != null && + typeof targetVal === 'object' && + !Array.isArray(targetVal) + ) { + result[key] = deepMerge(targetVal as AnyObject, sourceVal as AnyObject, depth + 1); + } else { + result[key] = sourceVal; + } + } + return result as T; +} + +/** + * Merge DB config overrides into a base AppConfig. + * + * Configs are sorted by priority ascending (lowest first, highest wins). + * Each config's `overrides` is deep-merged into the base config in order. + */ +export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): AppConfig { + if (!configs || configs.length === 0) { + return baseConfig; + } + + const sorted = [...configs].sort((a, b) => a.priority - b.priority); + + let merged = { ...baseConfig }; + for (const config of sorted) { + if (config.overrides && typeof config.overrides === 'object') { + merged = deepMerge(merged, config.overrides as AnyObject); + } + } + + return merged; +} diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index aa92b3b2e6..cd683c937c 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,5 +1,5 @@ export * from './app'; -export * from './systemCapabilities'; +export * from './admin'; export * from './common'; export * from './crypto'; export * from './schema'; diff --git a/packages/data-schemas/src/methods/config.spec.ts b/packages/data-schemas/src/methods/config.spec.ts new file mode 100644 index 0000000000..82f43c2b37 --- /dev/null +++ b/packages/data-schemas/src/methods/config.spec.ts @@ -0,0 +1,297 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import { createConfigMethods } from './config'; +import configSchema from '~/schema/config'; +import type { IConfig } from '~/types'; + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + if (!mongoose.models.Config) { + mongoose.model('Config', configSchema); + } + methods = createConfigMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.models.Config.deleteMany({}); +}); + +describe('upsertConfig', () => { + it('creates a new config document', async () => { + const result = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { endpointsMenu: false } }, + 10, + ); + + expect(result).toBeTruthy(); + expect(result!.principalType).toBe(PrincipalType.ROLE); + expect(result!.principalId).toBe('admin'); + expect(result!.priority).toBe(10); + expect(result!.isActive).toBe(true); + expect(result!.configVersion).toBe(1); + }); + + it('is idempotent — second upsert updates the same doc', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { endpointsMenu: false } }, + 10, + ); + + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { endpointsMenu: true } }, + 10, + ); + + const count = await mongoose.models.Config.countDocuments({}); + expect(count).toBe(1); + }); + + it('increments configVersion on each upsert', async () => { + const first = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { a: 1 }, + 10, + ); + + const second = await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { a: 2 }, + 10, + ); + + expect(first!.configVersion).toBe(1); + expect(second!.configVersion).toBe(2); + }); + + it('normalizes ObjectId principalId to string', async () => { + const oid = new Types.ObjectId(); + await methods.upsertConfig(PrincipalType.USER, oid, PrincipalModel.USER, { test: true }, 100); + + const found = await methods.findConfigByPrincipal(PrincipalType.USER, oid.toString()); + expect(found).toBeTruthy(); + expect(found!.principalId).toBe(oid.toString()); + }); +}); + +describe('findConfigByPrincipal', () => { + it('finds an active config', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { x: 1 }, 10); + + const result = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin'); + expect(result).toBeTruthy(); + expect(result!.principalType).toBe(PrincipalType.ROLE); + }); + + it('returns null when no config exists', async () => { + const result = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'nonexistent'); + expect(result).toBeNull(); + }); + + it('does not find inactive configs', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { x: 1 }, 10); + await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false); + + const result = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin'); + expect(result).toBeNull(); + }); +}); + +describe('listAllConfigs', () => { + it('returns all configs when no filter', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'a', PrincipalModel.ROLE, {}, 10); + await methods.upsertConfig(PrincipalType.ROLE, 'b', PrincipalModel.ROLE, {}, 20); + await methods.toggleConfigActive(PrincipalType.ROLE, 'b', false); + + const all = await methods.listAllConfigs(); + expect(all).toHaveLength(2); + }); + + it('filters by isActive when specified', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'a', PrincipalModel.ROLE, {}, 10); + await methods.upsertConfig(PrincipalType.ROLE, 'b', PrincipalModel.ROLE, {}, 20); + await methods.toggleConfigActive(PrincipalType.ROLE, 'b', false); + + const active = await methods.listAllConfigs({ isActive: true }); + expect(active).toHaveLength(1); + expect(active[0].principalId).toBe('a'); + + const inactive = await methods.listAllConfigs({ isActive: false }); + expect(inactive).toHaveLength(1); + expect(inactive[0].principalId).toBe('b'); + }); + + it('returns configs sorted by priority ascending', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'high', PrincipalModel.ROLE, {}, 100); + await methods.upsertConfig(PrincipalType.ROLE, 'low', PrincipalModel.ROLE, {}, 0); + await methods.upsertConfig(PrincipalType.ROLE, 'mid', PrincipalModel.ROLE, {}, 50); + + const configs = await methods.listAllConfigs(); + expect(configs.map((c) => c.principalId)).toEqual(['low', 'mid', 'high']); + }); +}); + +describe('getApplicableConfigs', () => { + it('always includes the __base__ config', async () => { + await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, { a: 1 }, 0); + + const configs = await methods.getApplicableConfigs([]); + expect(configs).toHaveLength(1); + expect(configs[0].principalId).toBe('__base__'); + }); + + it('returns base + matching principals', async () => { + await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, { a: 1 }, 0); + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, { b: 2 }, 10); + await methods.upsertConfig(PrincipalType.ROLE, 'user', PrincipalModel.ROLE, { c: 3 }, 10); + + const configs = await methods.getApplicableConfigs([ + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]); + + expect(configs).toHaveLength(2); + expect(configs.map((c) => c.principalId).sort()).toEqual(['__base__', 'admin']); + }); + + it('returns sorted by priority', async () => { + await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, {}, 0); + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + + const configs = await methods.getApplicableConfigs([ + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]); + + expect(configs[0].principalId).toBe('__base__'); + expect(configs[1].principalId).toBe('admin'); + }); + + it('skips principals with undefined principalId', async () => { + await methods.upsertConfig(PrincipalType.ROLE, '__base__', PrincipalModel.ROLE, {}, 0); + + const configs = await methods.getApplicableConfigs([ + { principalType: PrincipalType.GROUP, principalId: undefined }, + ]); + + expect(configs).toHaveLength(1); + }); +}); + +describe('patchConfigFields', () => { + it('atomically sets specific fields via $set', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { endpointsMenu: true, sidePanel: true } }, + 10, + ); + + const result = await methods.patchConfigFields( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { 'interface.endpointsMenu': false }, + 10, + ); + + const overrides = result!.overrides as Record; + const iface = overrides.interface as Record; + expect(iface.endpointsMenu).toBe(false); + expect(iface.sidePanel).toBe(true); + }); + + it('creates a config if none exists (upsert)', async () => { + const result = await methods.patchConfigFields( + PrincipalType.ROLE, + 'newrole', + PrincipalModel.ROLE, + { 'interface.endpointsMenu': false }, + 10, + ); + + expect(result).toBeTruthy(); + expect(result!.principalId).toBe('newrole'); + }); +}); + +describe('unsetConfigField', () => { + it('removes a field from overrides via $unset', async () => { + await methods.upsertConfig( + PrincipalType.ROLE, + 'admin', + PrincipalModel.ROLE, + { interface: { endpointsMenu: false, sidePanel: false } }, + 10, + ); + + const result = await methods.unsetConfigField( + PrincipalType.ROLE, + 'admin', + 'interface.endpointsMenu', + ); + const overrides = result!.overrides as Record; + const iface = overrides.interface as Record; + expect(iface.endpointsMenu).toBeUndefined(); + expect(iface.sidePanel).toBe(false); + }); + + it('returns null for non-existent config', async () => { + const result = await methods.unsetConfigField(PrincipalType.ROLE, 'ghost', 'a.b'); + expect(result).toBeNull(); + }); +}); + +describe('deleteConfig', () => { + it('deletes and returns the config', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + const deleted = await methods.deleteConfig(PrincipalType.ROLE, 'admin'); + expect(deleted).toBeTruthy(); + + const found = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin'); + expect(found).toBeNull(); + }); + + it('returns null when deleting non-existent config', async () => { + const result = await methods.deleteConfig(PrincipalType.ROLE, 'ghost'); + expect(result).toBeNull(); + }); +}); + +describe('toggleConfigActive', () => { + it('deactivates an active config', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + + const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false); + expect(result!.isActive).toBe(false); + }); + + it('reactivates an inactive config', async () => { + await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10); + await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false); + + const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', true); + expect(result!.isActive).toBe(true); + }); +}); diff --git a/packages/data-schemas/src/methods/config.ts b/packages/data-schemas/src/methods/config.ts new file mode 100644 index 0000000000..42047d216f --- /dev/null +++ b/packages/data-schemas/src/methods/config.ts @@ -0,0 +1,215 @@ +import { Types } from 'mongoose'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { Model, ClientSession } from 'mongoose'; +import type { IConfig } from '~/types'; + +export function createConfigMethods(mongoose: typeof import('mongoose')) { + async function findConfigByPrincipal( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + options?: { includeInactive?: boolean }, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + const filter: { principalType: PrincipalType; principalId: string; isActive?: boolean } = { + principalType, + principalId: principalId.toString(), + }; + if (!options?.includeInactive) { + filter.isActive = true; + } + return await Config.findOne(filter) + .session(session ?? null) + .lean(); + } + + async function listAllConfigs( + filter?: { isActive?: boolean }, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + const where: { isActive?: boolean } = {}; + if (filter?.isActive !== undefined) { + where.isActive = filter.isActive; + } + return await Config.find(where) + .sort({ priority: 1 }) + .session(session ?? null) + .lean(); + } + + async function getApplicableConfigs( + principals?: Array<{ principalType: string; principalId?: string | Types.ObjectId }>, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const basePrincipal = { + principalType: PrincipalType.ROLE as string, + principalId: BASE_CONFIG_PRINCIPAL_ID, + }; + + const principalsQuery = [basePrincipal]; + + if (principals && principals.length > 0) { + for (const p of principals) { + if (p.principalId !== undefined) { + principalsQuery.push({ + principalType: p.principalType, + principalId: p.principalId.toString(), + }); + } + } + } + + return await Config.find({ + $or: principalsQuery, + isActive: true, + }) + .sort({ priority: 1 }) + .session(session ?? null) + .lean(); + } + + async function upsertConfig( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + overrides: Partial, + priority: number, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const query = { + principalType, + principalId: principalId.toString(), + }; + + const update = { + $set: { + principalModel, + overrides, + priority, + isActive: true, + }, + $inc: { configVersion: 1 }, + }; + + const options = { + upsert: true, + new: true, + setDefaultsOnInsert: true, + ...(session ? { session } : {}), + }; + + try { + return await Config.findOneAndUpdate(query, update, options); + } catch (err: unknown) { + if ((err as { code?: number }).code === 11000) { + return await Config.findOneAndUpdate( + query, + { $set: update.$set, $inc: update.$inc }, + { new: true, ...(session ? { session } : {}) }, + ); + } + throw err; + } + } + + async function patchConfigFields( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + fields: Record, + priority: number, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const setPayload: { principalModel: PrincipalModel; priority: number; [key: string]: unknown } = + { + principalModel, + priority, + }; + + for (const [path, value] of Object.entries(fields)) { + setPayload[`overrides.${path}`] = value; + } + + const options = { + upsert: true, + new: true, + setDefaultsOnInsert: true, + ...(session ? { session } : {}), + }; + + return await Config.findOneAndUpdate( + { principalType, principalId: principalId.toString() }, + { $set: setPayload, $inc: { configVersion: 1 } }, + options, + ); + } + + async function unsetConfigField( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + fieldPath: string, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const options = { + new: true, + ...(session ? { session } : {}), + }; + + return await Config.findOneAndUpdate( + { principalType, principalId: principalId.toString() }, + { $unset: { [`overrides.${fieldPath}`]: '' }, $inc: { configVersion: 1 } }, + options, + ); + } + + async function deleteConfig( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + return await Config.findOneAndDelete({ + principalType, + principalId: principalId.toString(), + }).session(session ?? null); + } + + async function toggleConfigActive( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + isActive: boolean, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + return await Config.findOneAndUpdate( + { principalType, principalId: principalId.toString() }, + { $set: { isActive } }, + { new: true, ...(session ? { session } : {}) }, + ); + } + + return { + listAllConfigs, + findConfigByPrincipal, + getApplicableConfigs, + upsertConfig, + patchConfigFields, + unsetConfigField, + deleteConfig, + toggleConfigActive, + }; +} + +export type ConfigMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 11f00e7827..4202cac0eb 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -48,6 +48,8 @@ import { createSpendTokensMethods, type SpendTokensMethods } from './spendTokens import { createPromptMethods, type PromptMethods, type PromptDeps } from './prompt'; /* Tier 5 — Agent */ import { createAgentMethods, type AgentMethods, type AgentDeps } from './agent'; +/* Config */ +import { createConfigMethods, type ConfigMethods } from './config'; export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate }; @@ -80,7 +82,8 @@ export type AllMethods = UserMethods & TransactionMethods & SpendTokensMethods & PromptMethods & - AgentMethods; + AgentMethods & + ConfigMethods; /** Dependencies injected from the api layer into createMethods */ export interface CreateMethodsDeps { @@ -201,6 +204,8 @@ export function createMethods( ...promptMethods, /* Tier 5 */ ...agentMethods, + /* Config */ + ...createConfigMethods(mongoose), }; } @@ -235,4 +240,5 @@ export type { SpendTokensMethods, PromptMethods, AgentMethods, + ConfigMethods, }; diff --git a/packages/data-schemas/src/methods/systemGrant.spec.ts b/packages/data-schemas/src/methods/systemGrant.spec.ts index 188d31b544..b17285c761 100644 --- a/packages/data-schemas/src/methods/systemGrant.spec.ts +++ b/packages/data-schemas/src/methods/systemGrant.spec.ts @@ -2,8 +2,8 @@ import mongoose, { Types } from 'mongoose'; import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type * as t from '~/types'; -import type { SystemCapability } from '~/systemCapabilities'; -import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities'; +import type { SystemCapability } from '~/types/admin'; +import { SystemCapabilities, CapabilityImplications } from '~/admin/capabilities'; import { createSystemGrantMethods } from './systemGrant'; import systemGrantSchema from '~/schema/systemGrant'; import logger from '~/config/winston'; diff --git a/packages/data-schemas/src/methods/systemGrant.ts b/packages/data-schemas/src/methods/systemGrant.ts index f0f389d762..6071dd38c5 100644 --- a/packages/data-schemas/src/methods/systemGrant.ts +++ b/packages/data-schemas/src/methods/systemGrant.ts @@ -1,8 +1,8 @@ import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import type { Types, Model, ClientSession } from 'mongoose'; -import type { SystemCapability } from '~/systemCapabilities'; +import type { SystemCapability } from '~/types/admin'; import type { ISystemGrant } from '~/types'; -import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities'; +import { SystemCapabilities, CapabilityImplications } from '~/admin/capabilities'; import { normalizePrincipalId } from '~/utils/principal'; import logger from '~/config/winston'; diff --git a/packages/data-schemas/src/models/config.ts b/packages/data-schemas/src/models/config.ts new file mode 100644 index 0000000000..97c08ce1da --- /dev/null +++ b/packages/data-schemas/src/models/config.ts @@ -0,0 +1,8 @@ +import configSchema from '~/schema/config'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import type * as t from '~/types'; + +export function createConfigModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(configSchema); + return mongoose.models.Config || mongoose.model('Config', configSchema); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 44d94c6ab4..5a8e8f1c2c 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -27,6 +27,7 @@ import { createAccessRoleModel } from './accessRole'; import { createAclEntryModel } from './aclEntry'; import { createSystemGrantModel } from './systemGrant'; import { createGroupModel } from './group'; +import { createConfigModel } from './config'; /** * Creates all database models for all collections @@ -62,5 +63,6 @@ export function createModels(mongoose: typeof import('mongoose')) { AclEntry: createAclEntryModel(mongoose), SystemGrant: createSystemGrantModel(mongoose), Group: createGroupModel(mongoose), + Config: createConfigModel(mongoose), }; } diff --git a/packages/data-schemas/src/schema/config.ts b/packages/data-schemas/src/schema/config.ts new file mode 100644 index 0000000000..be3784d55e --- /dev/null +++ b/packages/data-schemas/src/schema/config.ts @@ -0,0 +1,55 @@ +import { Schema } from 'mongoose'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { IConfig } from '~/types'; + +const configSchema = new Schema( + { + principalType: { + type: String, + enum: Object.values(PrincipalType), + required: true, + index: true, + }, + principalId: { + type: String, + refPath: 'principalModel', + required: true, + index: true, + }, + principalModel: { + type: String, + enum: Object.values(PrincipalModel), + required: true, + }, + priority: { + type: Number, + required: true, + index: true, + }, + overrides: { + type: Schema.Types.Mixed, + default: {}, + }, + isActive: { + type: Boolean, + default: true, + index: true, + }, + configVersion: { + type: Number, + default: 0, + }, + tenantId: { + type: String, + index: true, + }, + }, + { timestamps: true }, +); + +// Enforce 1:1 principal-to-config (one config document per principal per tenant) +configSchema.index({ principalType: 1, principalId: 1, tenantId: 1 }, { unique: true }); +configSchema.index({ principalType: 1, principalId: 1, isActive: 1, tenantId: 1 }); +configSchema.index({ priority: 1, isActive: 1, tenantId: 1 }); + +export default configSchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 456eb03ac2..2a5eff658b 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -25,3 +25,4 @@ export { default as userSchema } from './user'; export { default as memorySchema } from './memory'; export { default as groupSchema } from './group'; export { default as systemGrantSchema } from './systemGrant'; +export { default as configSchema } from './config'; diff --git a/packages/data-schemas/src/schema/systemGrant.ts b/packages/data-schemas/src/schema/systemGrant.ts index 0366f6080d..a20a407bf1 100644 --- a/packages/data-schemas/src/schema/systemGrant.ts +++ b/packages/data-schemas/src/schema/systemGrant.ts @@ -1,7 +1,7 @@ import { Schema } from 'mongoose'; import { PrincipalType } from 'librechat-data-provider'; -import { SystemCapabilities } from '~/systemCapabilities'; -import type { SystemCapability } from '~/systemCapabilities'; +import { SystemCapabilities } from '~/admin/capabilities'; +import type { SystemCapability } from '~/types/admin'; import type { ISystemGrant } from '~/types'; const baseCapabilities = new Set(Object.values(SystemCapabilities)); diff --git a/packages/data-schemas/src/systemCapabilities.ts b/packages/data-schemas/src/systemCapabilities.ts deleted file mode 100644 index cf2acfbf88..0000000000 --- a/packages/data-schemas/src/systemCapabilities.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { z } from 'zod'; -import type { configSchema } from 'librechat-data-provider'; -import { ResourceType } from 'librechat-data-provider'; - -export const SystemCapabilities = { - ACCESS_ADMIN: 'access:admin', - READ_USERS: 'read:users', - MANAGE_USERS: 'manage:users', - READ_GROUPS: 'read:groups', - MANAGE_GROUPS: 'manage:groups', - READ_ROLES: 'read:roles', - MANAGE_ROLES: 'manage:roles', - READ_CONFIGS: 'read:configs', - MANAGE_CONFIGS: 'manage:configs', - ASSIGN_CONFIGS: 'assign:configs', - READ_USAGE: 'read:usage', - READ_AGENTS: 'read:agents', - MANAGE_AGENTS: 'manage:agents', - MANAGE_MCP_SERVERS: 'manage:mcpservers', - READ_PROMPTS: 'read:prompts', - MANAGE_PROMPTS: 'manage:prompts', - /** Reserved — not yet enforced by any middleware. Grant has no effect until assistant listing is gated. */ - READ_ASSISTANTS: 'read:assistants', - MANAGE_ASSISTANTS: 'manage:assistants', -} as const; - -/** Top-level keys of the configSchema from librechat.yaml. */ -export type ConfigSection = keyof z.infer; - -/** Principal types that can receive config overrides. */ -export type ConfigAssignTarget = 'user' | 'group' | 'role'; - -/** Base capabilities defined in the SystemCapabilities object. */ -type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities]; - -/** Section-level config capabilities derived from configSchema keys. */ -type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`; - -/** Principal-scoped config assignment capabilities. */ -type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`; - -/** - * Union of all valid capability strings: - * - Base capabilities from SystemCapabilities - * - Section-level config capabilities (manage:configs:
, read:configs:
) - * - Config assignment capabilities (assign:configs:) - */ -export type SystemCapability = - | BaseSystemCapability - | ConfigSectionCapability - | ConfigAssignCapability; - -/** - * Capabilities that are implied by holding a broader capability. - * When `hasCapability` checks for an implied capability, it first expands - * the principal's grant set — so granting `MANAGE_USERS` automatically - * satisfies a `READ_USERS` check without a separate grant. - * - * Implication is one-directional: `MANAGE_USERS` implies `READ_USERS`, - * but `READ_USERS` does NOT imply `MANAGE_USERS`. - */ -export const CapabilityImplications: Partial> = - { - [SystemCapabilities.MANAGE_USERS]: [SystemCapabilities.READ_USERS], - [SystemCapabilities.MANAGE_GROUPS]: [SystemCapabilities.READ_GROUPS], - [SystemCapabilities.MANAGE_ROLES]: [SystemCapabilities.READ_ROLES], - [SystemCapabilities.MANAGE_CONFIGS]: [SystemCapabilities.READ_CONFIGS], - [SystemCapabilities.MANAGE_AGENTS]: [SystemCapabilities.READ_AGENTS], - [SystemCapabilities.MANAGE_PROMPTS]: [SystemCapabilities.READ_PROMPTS], - [SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS], - }; - -/** - * Maps each ACL ResourceType to the SystemCapability that grants - * unrestricted management access. Typed as `Record` - * so adding a new ResourceType variant causes a compile error until a - * capability is assigned here. - */ -export const ResourceCapabilityMap: Record = { - [ResourceType.AGENT]: SystemCapabilities.MANAGE_AGENTS, - [ResourceType.PROMPTGROUP]: SystemCapabilities.MANAGE_PROMPTS, - [ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS, - [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, -}; - -/** - * Derives a section-level config management capability from a configSchema key. - * @example configCapability('endpoints') → 'manage:configs:endpoints' - * - * TODO: Section-level config capabilities are scaffolded but not yet active. - * To activate delegated config management: - * 1. Expose POST/DELETE /api/admin/grants endpoints (wiring grantCapability/revokeCapability) - * 2. Seed section-specific grants for delegated admin roles via those endpoints - * 3. Guard config write handlers with hasConfigCapability(user, section) - */ -export function configCapability(section: ConfigSection): `manage:configs:${ConfigSection}` { - return `manage:configs:${section}`; -} - -/** - * Derives a section-level config read capability from a configSchema key. - * @example readConfigCapability('endpoints') → 'read:configs:endpoints' - */ -export function readConfigCapability(section: ConfigSection): `read:configs:${ConfigSection}` { - return `read:configs:${section}`; -} diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts new file mode 100644 index 0000000000..99915f659d --- /dev/null +++ b/packages/data-schemas/src/types/admin.ts @@ -0,0 +1,126 @@ +import type { + PrincipalType, + PrincipalModel, + TCustomConfig, + z, + configSchema, +} from 'librechat-data-provider'; +import type { SystemCapabilities } from '~/admin/capabilities'; + +/* ── Capability types ───────────────────────────────────────────────── */ + +/** Base capabilities derived from the SystemCapabilities constant. */ +export type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities]; + +/** Principal types that can receive config overrides. */ +export type ConfigAssignTarget = 'user' | 'group' | 'role'; + +/** Top-level keys of the configSchema from librechat.yaml. */ +export type ConfigSection = keyof z.infer; + +/** Section-level config capabilities derived from configSchema keys. */ +type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`; + +/** Principal-scoped config assignment capabilities. */ +type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`; + +/** + * Union of all valid capability strings: + * - Base capabilities from SystemCapabilities + * - Section-level config capabilities (manage:configs:
, read:configs:
) + * - Config assignment capabilities (assign:configs:) + */ +export type SystemCapability = + | BaseSystemCapability + | ConfigSectionCapability + | ConfigAssignCapability; + +/** UI grouping of capabilities for the admin panel's capability editor. */ +export type CapabilityCategory = { + key: string; + labelKey: string; + capabilities: BaseSystemCapability[]; +}; + +/* ── Admin API response types ───────────────────────────────────────── */ + +/** Config document as returned by the admin API (no Mongoose internals). */ +export type AdminConfig = { + _id: string; + principalType: PrincipalType; + principalId: string; + principalModel: PrincipalModel; + priority: number; + overrides: Partial; + isActive: boolean; + configVersion: number; + tenantId?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type AdminConfigListResponse = { + configs: AdminConfig[]; +}; + +export type AdminConfigResponse = { + config: AdminConfig; +}; + +export type AdminConfigDeleteResponse = { + success: boolean; +}; + +/** Audit action types for grant changes. */ +export type AuditAction = 'grant_assigned' | 'grant_removed'; + +/** SystemGrant document as returned by the admin API. */ +export type AdminSystemGrant = { + id: string; + principalType: PrincipalType; + principalId: string; + capability: string; + grantedBy?: string; + grantedAt: string; + expiresAt?: string; +}; + +/** Audit log entry for grant changes as returned by the admin API. */ +export type AdminAuditLogEntry = { + id: string; + action: AuditAction; + actorId: string; + actorName: string; + targetPrincipalType: PrincipalType; + targetPrincipalId: string; + targetName: string; + capability: string; + timestamp: string; +}; + +/** Group as returned by the admin API. */ +export type AdminGroup = { + id: string; + name: string; + description: string; + memberCount: number; + topMembers: { name: string }[]; + isActive: boolean; +}; + +/** Member entry as returned by the admin API for group/role membership lists. */ +export type AdminMember = { + userId: string; + name: string; + email: string; + avatarUrl?: string; + joinedAt: string; +}; + +/** Minimal user info returned by user search endpoints. */ +export type AdminUserSearchResult = { + userId: string; + name: string; + email: string; + avatarUrl?: string; +}; diff --git a/packages/data-schemas/src/types/config.ts b/packages/data-schemas/src/types/config.ts new file mode 100644 index 0000000000..04e0ca58ab --- /dev/null +++ b/packages/data-schemas/src/types/config.ts @@ -0,0 +1,36 @@ +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { Document, Types } from 'mongoose'; + +/** + * Configuration override for a principal (user, group, or role). + * Stores partial overrides at the TCustomConfig (YAML) level, + * which are merged with the base config before processing through AppService. + */ +export type Config = { + /** The type of principal (user, group, role) */ + principalType: PrincipalType; + /** The ID of the principal (ObjectId for users/groups, string for roles) */ + principalId: Types.ObjectId | string; + /** The model name for the principal */ + principalModel: PrincipalModel; + /** Priority level for determining merge order (higher = more specific) */ + priority: number; + /** Configuration overrides matching librechat.yaml structure */ + overrides: Partial; + /** Whether this config override is currently active */ + isActive: boolean; + /** Version number for cache invalidation, auto-increments on overrides change */ + configVersion: number; + /** Tenant identifier for multi-tenancy isolation */ + tenantId?: string; + /** When this config was created */ + createdAt?: Date; + /** When this config was last updated */ + updatedAt?: Date; +}; + +export type IConfig = Config & + Document & { + _id: Types.ObjectId; + }; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 26238cbda1..748ea5d77d 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -28,6 +28,10 @@ export * from './accessRole'; export * from './aclEntry'; export * from './systemGrant'; export * from './group'; +/* Config */ +export * from './config'; +/* Admin */ +export * from './admin'; /* Web */ export * from './web'; /* MCP Servers */ diff --git a/packages/data-schemas/src/types/systemGrant.ts b/packages/data-schemas/src/types/systemGrant.ts index 9f0d576503..09cff1aec6 100644 --- a/packages/data-schemas/src/types/systemGrant.ts +++ b/packages/data-schemas/src/types/systemGrant.ts @@ -1,6 +1,6 @@ import type { Document, Types } from 'mongoose'; import type { PrincipalType } from 'librechat-data-provider'; -import type { SystemCapability } from '~/systemCapabilities'; +import type { SystemCapability } from '~/types/admin'; export type SystemGrant = { /** The type of principal — matches PrincipalType enum values */