🎛️ feat: DB-Backed Per-Principal Config System (#12354)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

*  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:
Danny Avila 2026-03-25 19:39:29 -04:00 committed by GitHub
parent f277b32030
commit 4b6d68b3b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2596 additions and 183 deletions

View 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 } });
});
});
});

View 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');
});
});

View 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,
};
}

View file

@ -0,0 +1,2 @@
export { createAdminConfigHandlers } from './config';
export type { AdminConfigDeps } from './config';

View file

@ -1,3 +1,4 @@
export * from './service';
export * from './config';
export * from './permissions';
export * from './cdn';

View 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);
});
});
});

View 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>;

View file

@ -1,4 +1,6 @@
export * from './app';
/* Admin */
export * from './admin';
export * from './cdn';
/* Auth */
export * from './auth';

View file

@ -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;
}