mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
🎛️ feat: DB-Backed Per-Principal Config System (#12354)
* ✨ feat: Add Config schema, model, and methods for role-based DB config overrides Add the database foundation for principal-based configuration overrides (user, group, role) in data-schemas. Includes schema with tenantId and tenant isolation, CRUD methods, and barrel exports. * 🔧 fix: Add shebang and enforce LF line endings for git hooks The pre-commit hook was missing #!/bin/sh, and core.autocrlf=true was converting it to CRLF, both causing "Exec format error" on Windows. Add .gitattributes to force LF for .husky/* and *.sh files. * ✨ feat: Add admin config API routes with section-level capability checks Add /api/admin/config endpoints for managing per-principal config overrides (user, group, role). Handlers in @librechat/api use DI pattern with section-level hasConfigCapability checks for granular access control. Supports full overrides replacement, per-field PATCH via dot-paths, field deletion, toggle active, and listing. * 🐛 fix: Move deleteConfigField fieldPath from URL param to request body The path-to-regexp wildcard syntax (:fieldPath(*)) is not supported by the version used in Express. Send fieldPath in the DELETE request body instead, which also avoids URL-encoding issues with dotted paths. * ✨ feat: Wire config resolution into getAppConfig with override caching Add mergeConfigOverrides utility in data-schemas for deep-merging DB config overrides into base AppConfig by priority order. Update getAppConfig to query DB for applicable configs when role/userId is provided, with short-TTL caching and a hasAnyConfigs feature flag for zero-cost when no DB configs exist. Also: add unique compound index on Config schema, pass userId from config middleware, and signal config changes from admin API handlers. * 🔄 refactor: Extract getAppConfig logic into packages/api as TS service Move override resolution, caching strategy, and signalConfigChange from api/server/services/Config/app.js into packages/api/src/app/appConfigService.ts using the DI factory pattern (createAppConfigService). The JS file becomes a thin wiring layer injecting loadBaseConfig, cache, and DB dependencies. * 🧹 chore: Rename configResolution.ts to resolution.ts * ✨ feat: Move admin types & capabilities to librechat-data-provider Move SystemCapabilities, CapabilityImplications, and utility functions (hasImpliedCapability, expandImplications) from data-schemas to data-provider so they are available to external consumers like the admin panel without a data-schemas dependency. Add API-friendly admin types: TAdminConfig, TAdminSystemGrant, TAdminAuditLogEntry, TAdminGroup, TAdminMember, TAdminUserSearchResult, TCapabilityCategory, and CAPABILITY_CATEGORIES. data-schemas re-exports these from data-provider and extends with config-schema-derived types (ConfigSection, SystemCapability union). Bump version to 0.8.500. * feat: Add JSON-serializable admin config API response types to data-schemas Add AdminConfig, AdminConfigListResponse, AdminConfigResponse, and AdminConfigDeleteResponse types so both LibreChat API handlers and the admin panel can share the same response contract. Bump version to 0.0.41. * refactor: Move admin capabilities & types from data-provider to data-schemas SystemCapabilities, CapabilityImplications, utility functions, CAPABILITY_CATEGORIES, and admin API response types should not be in data-provider as it gets compiled into the frontend bundle, exposing the capability surface. Moved everything to data-schemas (server-only). All consumers already import from @librechat/data-schemas, so no import changes needed elsewhere. Consolidated duplicate AdminConfig type (was in both config.ts and admin.ts). * chore: Bump @librechat/data-schemas to 0.0.42 * refactor: Reorganize admin capabilities into admin/ and types/admin.ts Split systemCapabilities.ts following data-schemas conventions: - Types (BaseSystemCapability, SystemCapability, AdminConfig, etc.) → src/types/admin.ts - Runtime code (SystemCapabilities, CapabilityImplications, utilities) → src/admin/capabilities.ts Revert data-provider version to 0.8.401 (no longer modified). * chore: Fix import ordering, rename appConfigService to service - Rename app/appConfigService.ts → app/service.ts (directory provides context) - Fix import order in admin/config.ts, types/admin.ts, types/config.ts - Add naming convention to AGENTS.md * feat: Add DB base config support (role/__base__) - Add BASE_CONFIG_PRINCIPAL_ID constant for reserved base config doc - getApplicableConfigs always includes __base__ in queries - getAppConfig queries DB even without role/userId when DB configs exist - Bump @librechat/data-schemas to 0.0.43 * fix: Address PR review issues for admin config - Add listAllConfigs method; listConfigs endpoint returns all active configs instead of only __base__ - Normalize principalId to string in all config methods to prevent ObjectId vs string mismatch on user/group lookups - Block __proto__ and all dunder-prefixed segments in field path validation to prevent prototype pollution - Fix configVersion off-by-one: default to 0, guard pre('save') with !isNew, use $inc on findOneAndUpdate - Remove unused getApplicableConfigs from admin handler deps * fix: Enable tree-shaking for data-schemas, bump packages - Switch data-schemas Rollup output to preserveModules so each source file becomes its own chunk; consumers (admin panel) can now import just the modules they need without pulling in winston/mongoose/etc. - Add sideEffects: false to data-schemas package.json - Bump data-schemas to 0.0.44, data-provider to 0.8.402 * feat: add capabilities subpath export to data-schemas Adds `@librechat/data-schemas/capabilities` subpath export so browser consumers can import BASE_CONFIG_PRINCIPAL_ID and capability constants without pulling in Node.js-only modules (winston, async_hooks, etc.). Bump version to 0.0.45. * fix: include dist/ in data-provider npm package Add explicit files field so npm includes dist/types/ in the published package. Without this, the root .gitignore exclusion of dist/ causes npm to omit type declarations, breaking TypeScript consumers. * chore: bump librechat-data-provider to 0.8.403 * feat: add GET /api/admin/config/base for raw AppConfig Returns the full AppConfig (YAML + DB base merged) so the admin panel can display actual config field values and structure. The startup config endpoint (/api/config) returns TStartupConfig which is a different shape meant for the frontend app. * chore: imports order * fix: address code review findings for admin config Critical: - Fix clearAppConfigCache: was deleting from wrong cache store (CONFIG_STORE instead of APP_CONFIG), now clears BASE and HAS_DB_CONFIGS keys - Eliminate race condition: patchConfigField and deleteConfigField now use atomic MongoDB $set/$unset with dot-path notation instead of read-modify-write cycles, removing the lost-update bug entirely - Add patchConfigFields and unsetConfigField atomic DB methods Major: - Reorder cache check before principal resolution in getAppConfig so getUserPrincipals DB query only fires on cache miss - Replace '' as ConfigSection with typed BROAD_CONFIG_ACCESS constant - Parallelize capability checks with Promise.all instead of sequential awaits in for loops - Use loose equality (== null) for cache miss check to handle both null and undefined returns from cache implementations - Set HAS_DB_CONFIGS_KEY to true on successful config fetch Minor: - Remove dead pre('save') hook from config schema (all writes use findOneAndUpdate which bypasses document hooks) - Consolidate duplicate type imports in resolution.ts - Remove dead deepGet/deepSet/deepUnset functions (replaced by atomic ops) - Add .sort({ priority: 1 }) to getApplicableConfigs query - Rename _impliedBy to impliedByMap * fix: self-referencing BROAD_CONFIG_ACCESS constant * fix: replace type-cast sentinel with proper null parameter Update hasConfigCapability to accept ConfigSection | null where null means broad access check (MANAGE_CONFIGS or READ_CONFIGS only). Removes the '' as ConfigSection type lie from admin config handlers. * fix: remaining review findings + add tests - listAllConfigs accepts optional { isActive } filter so admin listing can show inactive configs (#9) - Standardize session application to .session(session ?? null) across all config DB methods (#15) - Export isValidFieldPath and getTopLevelSection for testability - Add 38 tests across 3 spec files: - config.spec.ts (api): path validation, prototype pollution rejection - resolution.spec.ts: deep merge, priority ordering, array replacement - config.spec.ts (data-schemas): full CRUD, ObjectId normalization, atomic $set/$unset, configVersion increment, toggle, __base__ query * fix: address second code review findings - Fix cross-user cache contamination: overrideCacheKey now handles userId-without-role case with its own cache key (#1) - Add broad capability check before DB lookup in getConfig to prevent config existence enumeration (#2/#3) - Move deleteConfigField fieldPath from request body to query parameter for proxy/load balancer compatibility (#5) - Derive BaseSystemCapability from SystemCapabilities const instead of manual string union (#6) - Return 201 on upsert creation, 200 on update (#11) - Remove inline narration comments per AGENTS.md (#12) - Type overrides as Partial<TCustomConfig> in DB methods and handler deps (#13) - Replace double as-unknown-as casts in resolution.ts with generic deepMerge<T> (#14) - Make override cache TTL injectable via AppConfigServiceDeps (#16) - Add exhaustive never check in principalModel switch (#17) * fix: remaining review findings — tests, rename, semantics - Rename signalConfigChange → markConfigsDirty with JSDoc documenting the stale-window tradeoff and overrideCacheTtl knob - Fix DEFAULT_OVERRIDE_CACHE_TTL naming convention - Add createAppConfigService tests (14 cases): cache behavior, feature flag, cross-user key isolation, fallback on error, markConfigsDirty - Add admin handler integration tests (13 cases): auth ordering, 201/200 on create/update, fieldPath from query param, markConfigsDirty calls, capability checks * fix: global flag corruption + empty overrides auth bypass - Remove HAS_DB_CONFIGS_KEY=false optimization: a scoped query returning no configs does not mean no configs exist globally. Setting the flag false from a per-principal query short-circuited all subsequent users. - Add broad manage capability check before section checks in upsertConfigOverrides: empty overrides {} no longer bypasses auth. * test: add regression and invariant tests for config system Regression tests: - Bug 1: User A's empty result does not short-circuit User B's overrides - Bug 2: Empty overrides {} returns 403 without MANAGE_CONFIGS Invariant tests (applied across ALL handlers): - All 5 mutation handlers call markConfigsDirty on success - All 5 mutation handlers return 401 without auth - All 5 mutation handlers return 403 without capability - All 3 read handlers return 403 without capability * fix: third review pass — all findings addressed Service (service.ts): - Restore HAS_DB_CONFIGS=false for base-only queries (no role/userId) so deployments with zero DB configs skip DB queries (#1) - Resolve cache once at factory init instead of per-invocation (#8) - Use BASE_CONFIG_PRINCIPAL_ID constant in overrideCacheKey (#10) - Add JSDoc to clearAppConfigCache documenting stale-window (#4) - Fix log message to not say "from YAML" (#14) Admin handlers (config.ts): - Use configVersion===1 for 201 vs 200, eliminating TOCTOU race (#2) - Add Array.isArray guard on overrides body (#5) - Import CapabilityUser from capabilities.ts, remove duplicate (#6) - Replace as-unknown-as cast with targeted type assertion (#7) - Add MAX_PATCH_ENTRIES=100 cap on entries array (#15) - Reorder deleteConfigField to validate principalType first (#12) - Export CapabilityUser from middleware/capabilities.ts DB methods (config.ts): - Remove isActive:true from patchConfigFields to prevent silent reactivation of disabled configs (#3) Schema (config.ts): - Change principalId from Schema.Types.Mixed to String (#11) Tests: - Add patchConfigField unsafe fieldPath rejection test (#9) - Add base-only HAS_DB_CONFIGS=false test (#1) - Update 201/200 tests to use configVersion instead of findConfig (#2) * fix: add read handler 401 invariant tests + document flag behavior - Add invariant: all 3 read handlers return 401 without auth - Document on markConfigsDirty that HAS_DB_CONFIGS stays true after all configs are deleted until clearAppConfigCache or restart * fix: remove HAS_DB_CONFIGS false optimization entirely getApplicableConfigs([]) only queries for __base__, not all configs. A deployment with role/group configs but no __base__ doc gets the flag poisoned to false by a base-only query, silently ignoring all scoped overrides. The optimization is not safe without a comprehensive Config.exists() check, which adds its own DB cost. Removed entirely. The flag is now write-once-true (set when configs are found or by markConfigsDirty) and only cleared by clearAppConfigCache/restart. * chore: reorder import statements in app.js for clarity * refactor: remove HAS_DB_CONFIGS_KEY machinery entirely The three-state flag (false/null/true) was the source of multiple bugs across review rounds. Every attempt to safely set it to false was defeated by getApplicableConfigs querying only a subset of principals. Removed: HAS_DB_CONFIGS_KEY constant, all reads/writes of the flag, markConfigsDirty (now a no-op concept), notifyChange wrapper, and all tests that seeded false manually. The per-user/role TTL cache (overrideCacheTtl, default 60s) is the sole caching mechanism. On cache miss, getApplicableConfigs queries the DB. This is one indexed query per user per TTL window — acceptable for the config override use case. * docs: rewrite admin panel remaining work with current state * perf: cache empty override results to avoid repeated DB queries When getApplicableConfigs returns no configs for a principal, cache baseConfig under their override key with TTL. Without this, every user with no per-principal overrides hits MongoDB on every request after the 60s cache window expires. * fix: add tenantId to cache keys + reject PUBLIC principal type - Include tenantId in override cache keys to prevent cross-tenant config contamination. Single-tenant deployments (tenantId undefined) use '_' as placeholder — no behavior change for them. - Reject PrincipalType.PUBLIC in admin config validation — PUBLIC has no PrincipalModel and is never resolved by getApplicableConfigs, so config docs for it would be dead data. - Config middleware passes req.user.tenantId to getAppConfig. * fix: fourth review pass findings DB methods (config.ts): - findConfigByPrincipal accepts { includeInactive } option so admin GET can retrieve inactive configs (#5) - upsertConfig catches E11000 duplicate key on concurrent upserts and retries without upsert flag (#2) - unsetConfigField no longer filters isActive:true, consistent with patchConfigFields (#11) - Typed filter objects replace Record<string, unknown> (#12) Admin handlers (config.ts): - patchConfigField: serial broad capability check before Promise.all to pre-warm ALS principal cache, preventing N parallel DB calls (#3) - isValidFieldPath rejects leading/trailing dots and consecutive dots (#7) - Duplicate fieldPaths in patch entries return 400 (#8) - DEFAULT_PRIORITY named constant replaces hardcoded 10 (#14) - Admin getConfig and patchConfigField pass includeInactive to findConfigByPrincipal (#5) - Route import uses barrel instead of direct file path (#13) Resolution (resolution.ts): - deepMerge has MAX_MERGE_DEPTH=10 guard to prevent stack overflow from crafted deeply nested configs (#4) * fix: final review cleanup - Remove ADMIN_PANEL_REMAINING.md (local dev notes with Windows paths) - Add empty-result caching regression test - Add tenantId to AdminConfigDeps.getAppConfig type - Restore exhaustive never check in principalModel switch - Standardize toggleConfigActive session handling to options pattern * fix: validate priority in patchConfigField handler Add the same non-negative number validation for priority that upsertConfigOverrides already has. Without this, invalid priority values could be stored via PATCH and corrupt merge ordering. * chore: remove planning doc from PR * fix: correct stale cache key strings in service tests * fix: clean up service tests and harden tenant sentinel - Remove no-op cache delete lines from regression tests - Change no-tenant sentinel from '_' to '__default__' to avoid collision with a real tenant ID when multi-tenancy is enabled - Remove unused CONFIG_STORE from AppConfigServiceDeps * chore: bump @librechat/data-schemas to 0.0.46 * fix: block prototype-poisoning keys in deepMerge Skip __proto__, constructor, and prototype keys during config merge to prevent prototype pollution via PUT /api/admin/config overrides.
This commit is contained in:
parent
f277b32030
commit
4b6d68b3b5
40 changed files with 2596 additions and 183 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
39
api/server/routes/admin/config.js
Normal file
39
api/server/routes/admin/config.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AppConfig>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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,
|
||||
|
|
|
|||
414
packages/api/src/admin/config.handler.spec.ts
Normal file
414
packages/api/src/admin/config.handler.spec.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
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<string, (...args: unknown[]) => Promise<unknown>>)[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<string, (...args: unknown[]) => Promise<unknown>>)[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<string, unknown> }> = [
|
||||
{ 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<string, (...args: unknown[]) => Promise<unknown>>)[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<string, unknown> }> = [
|
||||
{ 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<string, (...args: unknown[]) => Promise<unknown>>)[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 } });
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/api/src/admin/config.spec.ts
Normal file
57
packages/api/src/admin/config.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
509
packages/api/src/admin/config.ts
Normal file
509
packages/api/src/admin/config.ts
Normal file
|
|
@ -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<IConfig[]>;
|
||||
findConfigByPrincipal: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
options?: { includeInactive?: boolean },
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
upsertConfig: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
principalModel: PrincipalModel,
|
||||
overrides: Partial<TCustomConfig>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
patchConfigFields: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
principalModel: PrincipalModel,
|
||||
fields: Record<string, unknown>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
unsetConfigField: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
fieldPath: string,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
deleteConfig: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
toggleConfigActive: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
isActive: boolean,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
hasConfigCapability: (
|
||||
user: CapabilityUser,
|
||||
section: ConfigSection | null,
|
||||
verb?: 'manage' | 'read',
|
||||
) => Promise<boolean>;
|
||||
getAppConfig?: (options?: {
|
||||
role?: string;
|
||||
userId?: string;
|
||||
tenantId?: string;
|
||||
}) => Promise<AppConfig>;
|
||||
}
|
||||
|
||||
// ── 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<TCustomConfig>;
|
||||
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<string>();
|
||||
const fields: Record<string, unknown> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
2
packages/api/src/admin/index.ts
Normal file
2
packages/api/src/admin/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { createAdminConfigHandlers } from './config';
|
||||
export type { AdminConfigDeps } from './config';
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './service';
|
||||
export * from './config';
|
||||
export * from './permissions';
|
||||
export * from './cdn';
|
||||
|
|
|
|||
244
packages/api/src/app/service.spec.ts
Normal file
244
packages/api/src/app/service.spec.ts
Normal file
|
|
@ -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<string, unknown>).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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
packages/api/src/app/service.ts
Normal file
155
packages/api/src/app/service.ts
Normal file
|
|
@ -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<unknown>;
|
||||
set: (key: string, value: unknown, ttl?: number) => Promise<unknown>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface AppConfigServiceDeps {
|
||||
/** Load the base AppConfig from YAML + AppService processing. */
|
||||
loadBaseConfig: () => Promise<AppConfig | undefined>;
|
||||
/** Cache tools after base config is loaded. */
|
||||
setCachedTools: (tools: Record<string, unknown>) => Promise<void>;
|
||||
/** 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<IConfig[]>;
|
||||
/** Resolve full principal list (user + role + groups) from userId/role. */
|
||||
getUserPrincipals: (params: {
|
||||
userId: string | Types.ObjectId;
|
||||
role?: string | null;
|
||||
}) => Promise<Array<{ principalType: string; principalId?: string | Types.ObjectId }>>;
|
||||
/** 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<Array<{ principalType: string; principalId?: string | Types.ObjectId }>> {
|
||||
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<AppConfig> {
|
||||
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<void> {
|
||||
await cache.delete(BASE_CONFIG_KEY);
|
||||
}
|
||||
|
||||
return {
|
||||
getAppConfig,
|
||||
clearAppConfigCache,
|
||||
};
|
||||
}
|
||||
|
||||
export type AppConfigService = ReturnType<typeof createAppConfigService>;
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from './app';
|
||||
/* Admin */
|
||||
export * from './admin';
|
||||
export * from './cdn';
|
||||
/* Auth */
|
||||
export * from './auth';
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface CapabilityDeps {
|
|||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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<boolean>;
|
||||
|
||||
|
|
@ -138,11 +138,14 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
|
|||
*/
|
||||
async function hasConfigCapability(
|
||||
user: CapabilityUser,
|
||||
section: ConfigSection,
|
||||
section: ConfigSection | null,
|
||||
verb: 'manage' | 'read' = 'manage',
|
||||
): Promise<boolean> {
|
||||
const broadCap =
|
||||
verb === 'manage' ? SystemCapabilities.MANAGE_CONFIGS : SystemCapabilities.READ_CONFIGS;
|
||||
if (section == null) {
|
||||
return hasCapability(user, broadCap);
|
||||
}
|
||||
if (await hasCapability(user, broadCap)) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
199
packages/data-schemas/src/admin/capabilities.ts
Normal file
199
packages/data-schemas/src/admin/capabilities.ts
Normal file
|
|
@ -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<Record<BaseSystemCapability, BaseSystemCapability[]>> =
|
||||
{
|
||||
[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<string, string[]> = {};
|
||||
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<ResourceType, …>`
|
||||
* so adding a new ResourceType variant causes a compile error until a
|
||||
* capability is assigned here.
|
||||
*/
|
||||
export const ResourceCapabilityMap: Record<ResourceType, SystemCapability> = {
|
||||
[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],
|
||||
},
|
||||
];
|
||||
1
packages/data-schemas/src/admin/index.ts
Normal file
1
packages/data-schemas/src/admin/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './capabilities';
|
||||
|
|
@ -5,3 +5,4 @@ export * from './specs';
|
|||
export * from './turnstile';
|
||||
export * from './vertex';
|
||||
export * from './web';
|
||||
export * from './resolution';
|
||||
|
|
|
|||
108
packages/data-schemas/src/app/resolution.spec.ts
Normal file
108
packages/data-schemas/src/app/resolution.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { mergeConfigOverrides } from './resolution';
|
||||
import type { AppConfig, IConfig } from '~/types';
|
||||
|
||||
function fakeConfig(overrides: Record<string, unknown>, 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<string, unknown>;
|
||||
const iface = result.interface as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const reg = result.registration as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const iface = result.interface as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBeNull();
|
||||
});
|
||||
|
||||
it('skips configs with no overrides object', () => {
|
||||
const configs = [fakeConfig(undefined as unknown as Record<string, unknown>, 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<string, unknown>;
|
||||
expect(result.safe).toBe('ok');
|
||||
expect(({} as Record<string, unknown>).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<string, unknown>;
|
||||
const iface = result.interface as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBe(true);
|
||||
expect(iface.sidePanel).toBe(true);
|
||||
});
|
||||
});
|
||||
54
packages/data-schemas/src/app/resolution.ts
Normal file
54
packages/data-schemas/src/app/resolution.ts
Normal file
|
|
@ -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<T extends AnyObject>(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;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './app';
|
||||
export * from './systemCapabilities';
|
||||
export * from './admin';
|
||||
export * from './common';
|
||||
export * from './crypto';
|
||||
export * from './schema';
|
||||
|
|
|
|||
297
packages/data-schemas/src/methods/config.spec.ts
Normal file
297
packages/data-schemas/src/methods/config.spec.ts
Normal file
|
|
@ -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<typeof createConfigMethods>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
if (!mongoose.models.Config) {
|
||||
mongoose.model<IConfig>('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<string, unknown>;
|
||||
const iface = overrides.interface as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const iface = overrides.interface as Record<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
215
packages/data-schemas/src/methods/config.ts
Normal file
215
packages/data-schemas/src/methods/config.ts
Normal file
|
|
@ -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<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
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<IConfig[]> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
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<IConfig[]> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
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<TCustomConfig>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
): Promise<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
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<string, unknown>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
): Promise<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
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<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
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<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
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<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
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<typeof createConfigMethods>;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
8
packages/data-schemas/src/models/config.ts
Normal file
8
packages/data-schemas/src/models/config.ts
Normal file
|
|
@ -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<t.IConfig>('Config', configSchema);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
55
packages/data-schemas/src/schema/config.ts
Normal file
55
packages/data-schemas/src/schema/config.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Schema } from 'mongoose';
|
||||
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
|
||||
import type { IConfig } from '~/types';
|
||||
|
||||
const configSchema = new Schema<IConfig>(
|
||||
{
|
||||
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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<SystemCapability>(Object.values(SystemCapabilities));
|
||||
|
|
|
|||
|
|
@ -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<typeof configSchema>;
|
||||
|
||||
/** 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:<section>, read:configs:<section>)
|
||||
* - Config assignment capabilities (assign:configs:<user|group|role>)
|
||||
*/
|
||||
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<Record<BaseSystemCapability, BaseSystemCapability[]>> =
|
||||
{
|
||||
[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<ResourceType, …>`
|
||||
* so adding a new ResourceType variant causes a compile error until a
|
||||
* capability is assigned here.
|
||||
*/
|
||||
export const ResourceCapabilityMap: Record<ResourceType, SystemCapability> = {
|
||||
[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}`;
|
||||
}
|
||||
126
packages/data-schemas/src/types/admin.ts
Normal file
126
packages/data-schemas/src/types/admin.ts
Normal file
|
|
@ -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<typeof configSchema>;
|
||||
|
||||
/** 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:<section>, read:configs:<section>)
|
||||
* - Config assignment capabilities (assign:configs:<user|group|role>)
|
||||
*/
|
||||
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<TCustomConfig>;
|
||||
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;
|
||||
};
|
||||
36
packages/data-schemas/src/types/config.ts
Normal file
36
packages/data-schemas/src/types/config.ts
Normal file
|
|
@ -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<TCustomConfig>;
|
||||
/** 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;
|
||||
};
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue