WIP: first pass for DB Configs

This commit is contained in:
Danny Avila 2026-01-11 17:53:35 -05:00
parent c9bbb737a5
commit 429f742c85
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
15 changed files with 765 additions and 44 deletions

258
ROLE_BASED_CONFIG.md Normal file
View file

@ -0,0 +1,258 @@
# Role-Based Configuration Architecture
This document outlines the foundational architecture for role-based configuration management in LibreChat.
## Overview
The new architecture extends LibreChat's configuration system to support role, group, and user-specific configuration overrides while maintaining the existing `librechat.yaml` as the base configuration. The system uses a **priority-based merge strategy** where configurations are applied in order from lowest to highest priority.
## Architecture Components
### 1. Database Schema
A single unified MongoDB collection following the ACL pattern:
#### `Config`
- **Purpose**: Store configurations for any principal (user, group, or role)
- **Key Fields**:
- `principalType`: Type of entity (`PrincipalType.USER`, `PrincipalType.GROUP`, `PrincipalType.ROLE`)
- `principalId`: ID of the entity (ObjectId for users/groups, string for roles)
- `principalModel`: Model reference (`PrincipalModel.USER`, `PrincipalModel.GROUP`, `PrincipalModel.ROLE`)
- `priority`: Priority level for merge order (higher = more specific)
- `overrides`: Object matching `librechat.yaml` structure
- `isActive`: Toggle to enable/disable
- `configVersion`: Auto-increments for cache invalidation
**Location**:
- Type: `packages/data-schemas/src/types/config.ts`
- Schema: `packages/data-schemas/src/schema/config.ts`
- Model: `packages/data-schemas/src/models/config.ts`
**Design**: This follows LibreChat's existing ACL pattern (`AclEntry`), using a single collection for all principal types rather than separate collections per type.
### 2. Database Methods
**File**: `packages/data-schemas/src/methods/config.ts` (exported via `@librechat/data-schemas`)
Database operations for config management:
#### Key Methods:
- **`getApplicableConfigs(principals)`**: Fetches all configs for given principals
- Takes array of principals from `getUserPrincipals`
- Single optimized `$or` query to DB
- Returns array of `IConfig` documents
- **`findConfigByPrincipal(principalType, principalId)`**: Find config for specific principal
- **`upsertConfig(...)`**: Create or update a config
- **`deleteConfig(...)`**: Delete a config
- **`toggleConfigActive(...)`**: Enable/disable a config
### 3. Configuration Resolution Service
**File**: `packages/api/src/config/resolution.ts` (exported via `@librechat/api`)
Simple service for merging configurations:
#### Key Functions:
- **`buildUserConfig({ baseConfig, cachedConfigs })`**: Merges base config with overrides
- Takes fresh `baseConfig` from YAML
- Takes cached `IConfig[]` documents
- Returns merged `TCustomConfig`
- Uses `deepmerge` npm package for merging
- **`mergeConfigsFromDB(baseConfig, configs)`**: Internal helper
- Sorts configs by priority (ascending)
- Merges each config's overrides in order
### 4. Updated `getAppConfig`
**File**: `api/server/services/Config/app.js`
The main config accessor with granular caching:
- `userId` or `role`: Determines which configs to apply
- **Granular caching**: Each config cached individually by principal
- Cache keys: `config:{principalType}:{principalId}`
- Graceful fallback to base config on errors
## Configuration Priority System
Configurations are applied in order from lowest to highest priority:
```
Priority 0: Base YAML config (librechat.yaml)
Priority X: All Config entries sorted by priority field
```
**Important**:
- Priority values are **not hardcoded** - each Config has its own priority
- Suggested defaults: Role (10-30), Group (20-50), User (100+)
- Higher priority values always override lower ones
- If a user belongs to multiple groups, each group config is applied in priority order
## How It Works
### Request Flow
1. **Request comes in** with `userId` and/or `role`
2. **Load base config** from `librechat.yaml` (cached as `BASE_CONFIG_KEY`)
3. **Get user principals** via `getUserPrincipals({ userId, role, includeGroups: false })`
- Returns array: user, role (no groups for initial implementation)
4. **Check cache individually** for each principal:
- `config:role:admin` → admin role config
- `config:user:123` → user 123's config
5. **If any cache miss**:
- Single optimized DB query fetches all missing configs
- Cache each returned config individually
6. **Merge fresh baseConfig** with all configs (cached + freshly fetched)
7. **Return** the final merged config
### Cache Strategy
**Granular individual caching**:
- `BASE_CONFIG_KEY`: The full processed AppConfig from YAML
- `config:role:{roleName}`: Role config from DB (shared across all users with that role)
- `config:user:{userId}`: User-specific config from DB
**Benefits**:
- **Efficient**: Role configs shared across users (e.g., all admins share `config:role:admin`)
- **Granular invalidation**: Change role config? Only clear that one cache entry
- **YAML independent**: Base config changes don't affect config caches
- **Optimized queries**: Single DB query for all missing configs
### Example Scenario
```javascript
// User "alice" has:
// - Role: "developer"
// Config: { principalType: 'role', principalId: 'developer', priority: 10 }
// - Groups: ["engineering-team", "beta-testers"]
// Configs: [
// { principalType: 'group', principalId: ObjectId(engineering-team), priority: 25 },
// { principalType: 'group', principalId: ObjectId(beta-testers), priority: 30 }
// ]
// - User config:
// Config: { principalType: 'user', principalId: ObjectId(alice), priority: 100 }
// Merge order:
// 1. Base YAML (priority 0)
// 2. Role "developer" config (priority 10)
// 3. Group "engineering-team" config (priority 25)
// 4. Group "beta-testers" config (priority 30)
// 5. User "alice" config (priority 100)
```
## Integration Points
### Current Integration
- Uses existing ACL system for group membership (`getUserGroups`)
- Maintains existing role system (1:1 user-role relationship)
- Preserves existing cache infrastructure (Redis/in-memory)
- Compatible with current `librechat.yaml` structure
### What's NOT Yet Implemented
This is the **foundation only**. Still needed:
1. **Admin UI**: Interface to create/edit configs
2. **API Endpoints**: REST API for CRUD operations on configs
3. **Validation**: Schema validation for config objects
4. **Cache Invalidation**: Pub/sub for multi-instance cache invalidation
5. **Testing**: Integration and unit tests
6. **Migration Tools**: Scripts to help migrate existing configs
7. **Documentation**: Admin guide for managing configs
8. **Audit Logging**: Track who changed what configs when
## Usage Example
```javascript
// In a request handler
const { getAppConfig } = require('~/server/services/Config/app');
// Get config for specific user
const config = await getAppConfig({ userId: req.user.id });
// Config will include all applicable overrides merged in priority order
console.log(config.endpoints); // User's effective endpoint config
```
## File Structure
```
packages/data-schemas/src/
├── types/
│ └── config.ts # IConfig type definitions
├── schema/
│ └── config.ts # Config Mongoose schema
├── models/
│ └── config.ts # Config model factory
└── methods/
└── config.ts # Config DB operations
packages/api/src/
├── config/
│ └── resolution.ts # Config resolution service (uses TCustomConfig)
└── types/
└── config.ts # AppConfig type
api/server/services/Config/
└── app.js # Updated getAppConfig (main entry point)
```
**Note**: Uses `deepmerge` npm package for merging (not custom implementation)
## Next Steps
To build on this foundation, consider implementing in this order:
1. **Create API endpoints** for managing configs (CRUD)
2. **Add validation** to ensure config objects match expected structure
3. **Build simple CLI tools** to test config creation/assignment
4. **Add cache invalidation** logic (pub/sub for Redis)
5. **Create admin UI** for visual config management
6. **Write tests** for the resolution logic
7. **Document** the config format and best practices
## Design Decisions
### Why Priority-Based?
- **Predictable**: Always know which config wins
- **Flexible**: Can adjust priority for special cases
- **Scalable**: Works with complex org hierarchies
### Why Single Schema (ACL Pattern)?
- **Consistency**: Matches existing LibreChat ACL architecture
- **Simplicity**: One collection instead of three
- **Flexibility**: Easy to add new principal types in the future
- **Queries**: Simple to fetch all overrides for any principal
### Why MongoDB?
- **Flexible Schema**: `overrides` can match any YAML structure
- **Already in use**: No new dependencies
- **ACL Integration**: Leverages existing group/role system
### Why Granular Caching?
- **Individual cache entries**: Each config cached by `config:{type}:{id}`
- **Efficient sharing**: All admins share `config:role:admin` cache
- **Granular invalidation**: Change one config? Clear only that cache entry
- **YAML independence**: Base config changes don't invalidate config caches
- **Optimized queries**: Single DB query for all cache misses
- **Fresh merges**: Each request merges fresh baseConfig with cached overrides
## Type Safety & Architecture
- **DB Layer** (`data-schemas`): Uses `IConfig` interface for DB documents
- **App Layer** (`api`): Uses `TCustomConfig` from `librechat-data-provider` for type safety
- **No `any` types**: All types properly defined and enforced
- **Separation of Concerns**: DB methods in data-schemas, business logic in api
- **Optimized Queries**: Uses `getUserPrincipals` to batch principal lookup
- **Smart Caching**: Caches raw DB overrides separately from base config
## Notes
- The `overrides` field uses `Schema.Types.Mixed` to support any structure matching `librechat.yaml`
- Typed as `Partial<TCustomConfig>` at the app layer for type safety
- Priority values are just defaults - they can be customized per config
- All configs have `isActive` flag for easy enable/disable without deletion
- `configVersion` auto-increments on changes to help with cache invalidation

View file

@ -1,6 +1,8 @@
const { CacheKeys } = require('librechat-data-provider');
const { buildUserConfig } = require('@librechat/api');
const { logger, AppService } = require('@librechat/data-schemas');
const { CacheKeys, PrincipalType } = require('librechat-data-provider');
const { loadAndFormatTools } = require('~/server/services/start/tools');
const { getUserPrincipals, getApplicableConfigs } = require('~/models');
const loadCustomConfig = require('./loadCustomConfig');
const { setCachedTools } = require('./getCachedTools');
const getLogStores = require('~/cache/getLogStores');
@ -23,26 +25,19 @@ const loadBaseConfig = async () => {
/**
* Get the app configuration based on user context
* @param {Object} [options]
* @param {string} [options.userId] - User ID (used to determine role if role not provided)
* @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 { userId, 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...');
if (!baseConfig || refresh) {
logger.info('[getAppConfig] Loading base configuration from YAML...');
baseConfig = await loadBaseConfig();
if (!baseConfig) {
@ -56,16 +51,67 @@ async function getAppConfig(options = {}) {
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;
if (!role && !userId) {
return baseConfig;
}
return baseConfig;
const principals = await getUserPrincipals({ userId, role, includeGroups: false });
if (principals.length === 0) {
return baseConfig;
}
const configs = [];
const missingPrincipals = [];
for (const principal of principals) {
const configCacheKey = `config:${principal.principalType}:${principal.principalId}`;
if (!refresh) {
const cachedConfig = await cache.get(configCacheKey);
if (cachedConfig) {
configs.push(cachedConfig);
continue;
}
}
// Track which principals we need to fetch from DB, only roles for now
if (principal.principalType === PrincipalType.ROLE) {
missingPrincipals.push(principal);
}
}
// Fetch missing configs from DB with single optimized query
if (missingPrincipals.length > 0) {
try {
// Single $or query fetches all missing configs at once
const dbConfigs = await getApplicableConfigs(missingPrincipals);
// Cache each returned config individually
for (const config of dbConfigs) {
const configCacheKey = `config:${config.principalType}:${config.principalId}`;
await cache.set(configCacheKey, config);
configs.push(config);
}
} catch (error) {
logger.error('[getAppConfig] Error fetching configs from DB:', error);
}
}
// No configs found, return base config
if (configs.length === 0) {
return baseConfig;
}
// Merge fresh baseConfig with configs on each request
try {
return await buildUserConfig({
baseConfig,
cachedConfigs: configs,
});
} catch (error) {
logger.error('[getAppConfig] Error merging configs, falling back to base:', error);
return baseConfig;
}
}
/**

View file

@ -8,15 +8,18 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { VitePWA } from 'vite-plugin-pwa';
// https://vitejs.dev/config/
const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080;
const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`;
const backendPort = (process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT)) || 3080;
const backendURL = process.env.HOST
? `http://${process.env.HOST}:${backendPort}`
: `http://localhost:${backendPort}`;
export default defineConfig(({ command }) => ({
base: '',
server: {
allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [],
allowedHosts:
(process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',')) || [],
host: process.env.HOST || 'localhost',
port: process.env.PORT && Number(process.env.PORT) || 3090,
port: (process.env.PORT && Number(process.env.PORT)) || 3090,
strictPort: false,
proxy: {
'/api': {
@ -54,7 +57,7 @@ export default defineConfig(({ command }) => ({
],
globIgnores: ['images/**/*', '**/*.map', 'index.html'],
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
navigateFallbackDenylist: [/^\/oauth/, /^\/api/, /^\/api\/admin\/oauth/],
},
includeAssets: [],
manifest: {

View file

@ -1,14 +1,16 @@
import { getTransactionsConfig, getBalanceConfig } from './config';
import { logger } from '@librechat/data-schemas';
import { FileSources } from 'librechat-data-provider';
import { EImageOutputType, FileSources } from 'librechat-data-provider';
import type { TCustomConfig } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { getTransactionsConfig, getBalanceConfig } from './config';
// Helper function to create a minimal AppConfig for testing
const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
const minimalConfig: TCustomConfig = {
version: '1.0.0',
cache: true,
imageOutputType: EImageOutputType.PNG,
fileStrategy: FileSources.local,
interface: {
endpointsMenu: true,
},
@ -60,7 +62,14 @@ describe('getTransactionsConfig', () => {
it('should return transactions config when explicitly set to false', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: false },
balance: { enabled: false },
balance: {
enabled: false,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: false });
@ -70,7 +79,14 @@ describe('getTransactionsConfig', () => {
it('should return transactions config when explicitly set to true', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: true },
balance: { enabled: false },
balance: {
enabled: false,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: true });
@ -79,7 +95,14 @@ describe('getTransactionsConfig', () => {
it('should return default config when transactions is not defined', () => {
const appConfig = createTestAppConfig({
balance: { enabled: false },
balance: {
enabled: false,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: true });
@ -90,7 +113,14 @@ describe('getTransactionsConfig', () => {
it('should force transactions to be enabled when balance is enabled but transactions is disabled', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: false },
balance: { enabled: true },
balance: {
enabled: true,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: true });
@ -103,7 +133,14 @@ describe('getTransactionsConfig', () => {
it('should not override transactions when balance is enabled and transactions is enabled', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: true },
balance: { enabled: true },
balance: {
enabled: true,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: true });
@ -113,7 +150,14 @@ describe('getTransactionsConfig', () => {
it('should allow transactions to be disabled when balance is disabled', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: false },
balance: { enabled: false },
balance: {
enabled: false,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: false });
@ -122,7 +166,14 @@ describe('getTransactionsConfig', () => {
it('should use default when balance is enabled but transactions is not defined', () => {
const appConfig = createTestAppConfig({
balance: { enabled: true },
balance: {
enabled: true,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: true });
@ -187,8 +238,14 @@ describe('getTransactionsConfig', () => {
it('should handle appConfig with balance enabled undefined', () => {
const appConfig = createTestAppConfig({
transactions: { enabled: false },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
balance: { enabled: undefined as any },
balance: {
enabled: undefined,
startBalance: 0,
autoRefillEnabled: false,
refillIntervalValue: 0,
refillIntervalUnit: 'seconds',
refillAmount: 0,
},
});
const result = getTransactionsConfig(appConfig);
expect(result).toEqual({ enabled: false });
@ -248,6 +305,9 @@ describe('getBalanceConfig', () => {
enabled: false,
startBalance: 2000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 1000,
},
});
const result = getBalanceConfig(appConfig);
@ -255,6 +315,9 @@ describe('getBalanceConfig', () => {
enabled: false,
startBalance: 2000,
autoRefillEnabled: true,
refillIntervalValue: 30,
refillIntervalUnit: 'days',
refillAmount: 1000,
});
});

View file

@ -0,0 +1,51 @@
import deepmerge from 'deepmerge';
import { logger } from '@librechat/data-schemas';
import type { IConfig, AppConfig } from '@librechat/data-schemas';
/**
* Merge multiple configs in priority order
* @param baseConfig - The base AppConfig from AppService
* @param configs - Array of config documents from DB
* @returns The merged AppConfig
*/
function mergeConfigsFromDB(baseConfig: AppConfig, configs: IConfig[]): AppConfig {
// Sort by priority (ascending - lowest to highest)
const sortedConfigs = [...configs].sort((a, b) => a.priority - b.priority);
let finalConfig = { ...baseConfig };
for (const config of sortedConfigs) {
const source = `${config.principalType}:${config.principalId}`;
logger.debug(
`[configResolution] Applying config from: ${source} (priority: ${config.priority})`,
);
finalConfig = deepmerge(finalConfig, config.overrides);
}
return finalConfig;
}
/**
* Build the final merged configuration
* @param params - Parameters object
* @param params.baseConfig - The base AppConfig from AppService (always fresh)
* @param params.cachedConfigs - Cached DB configs to merge
* @returns The merged AppConfig
*/
export async function buildUserConfig({
baseConfig,
cachedConfigs,
}: {
baseConfig: AppConfig;
cachedConfigs: IConfig[];
}): Promise<AppConfig> {
if (!cachedConfigs || cachedConfigs.length === 0) {
return baseConfig;
}
logger.debug(`[configResolution] Merging ${cachedConfigs.length} config(s) with base config`);
// Merge fresh baseConfig with configs
return mergeConfigsFromDB(baseConfig, cachedConfigs);
}

View file

@ -9,6 +9,8 @@ export * from './mcp/connection';
export * from './mcp/oauth';
export * from './mcp/auth';
export * from './mcp/zod';
/* Config */
export * from './config/resolution';
export * from './mcp/errors';
/* Utilities */
export * from './mcp/utils';

View file

@ -52,6 +52,14 @@ export const ErrorController = (
}
const error = err as CustomError;
if (req.originalUrl && req.originalUrl.includes('/admin/oauth/')) {
return res.redirect(
(process.env.ADMIN_PANEL_URL || 'http://localhost:3000') +
'/login?error=' +
ErrorTypes.AUTH_FAILED,
);
}
if (
(error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) &&
req.originalUrl &&

View file

@ -0,0 +1,179 @@
import { Types } from 'mongoose';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { Model, ClientSession } from 'mongoose';
import type { IConfig } from '~/types';
export function createConfigMethods(mongoose: typeof import('mongoose')) {
/**
* Find a config by principal
* @param principalType - The type of principal
* @param principalId - The ID of the principal
* @param session - Optional MongoDB session for transactions
* @returns The config document or null if not found
*/
async function findConfigByPrincipal(
principalType: PrincipalType,
principalId: string | Types.ObjectId,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = mongoose.models.Config as Model<IConfig>;
const query = Config.findOne({
principalType,
principalId,
isActive: true,
});
if (session) {
query.session(session);
}
return await query.lean();
}
/**
* Get all applicable configurations based on principals
* @param principals - Optional list of principals (from getUserPrincipals)
* @param session - Optional MongoDB session for transactions
* @returns Array of applicable config documents
*/
async function getApplicableConfigs(
principals?: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
session?: ClientSession,
): Promise<IConfig[]> {
if (!principals || principals.length === 0) {
return [];
}
const Config = mongoose.models.Config as Model<IConfig>;
// Build query to get all configs matching any of the principals
const principalsQuery = principals
.map((p) => ({
principalType: p.principalType,
principalId: p.principalId,
}))
.filter((p) => p.principalId !== undefined); // Filter out PUBLIC since configs don't apply to PUBLIC
if (principalsQuery.length === 0) {
return [];
}
// Single query to get all applicable configs
const configQuery = Config.find({
$or: principalsQuery,
isActive: true,
});
if (session) {
configQuery.session(session);
}
return await configQuery.lean();
}
/**
* Create or update a config for a principal
* @param principalType - The type of principal
* @param principalId - The ID of the principal
* @param principalModel - The model reference
* @param overrides - The configuration overrides
* @param priority - The priority level
* @param session - Optional MongoDB session for transactions
* @returns The created or updated config document
*/
async function upsertConfig(
principalType: PrincipalType,
principalId: string | Types.ObjectId,
principalModel: PrincipalModel,
overrides: Record<string, unknown>,
priority: number,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = mongoose.models.Config as Model<IConfig>;
const query = {
principalType,
principalId,
};
const update = {
$set: {
principalModel,
overrides,
priority,
isActive: true,
},
};
const options = {
upsert: true,
new: true,
...(session ? { session } : {}),
};
return await Config.findOneAndUpdate(query, update, options);
}
/**
* Delete a config for a principal
* @param principalType - The type of principal
* @param principalId - The ID of the principal
* @param session - Optional MongoDB session for transactions
* @returns The deleted config document or null
*/
async function deleteConfig(
principalType: PrincipalType,
principalId: string | Types.ObjectId,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = mongoose.models.Config as Model<IConfig>;
const query = Config.findOneAndDelete({
principalType,
principalId,
});
if (session) {
query.session(session);
}
return await query;
}
/**
* Toggle active status of a config
* @param principalType - The type of principal
* @param principalId - The ID of the principal
* @param isActive - Whether the config should be active
* @param session - Optional MongoDB session for transactions
* @returns The updated config document or 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>;
const query = Config.findOneAndUpdate(
{ principalType, principalId },
{ $set: { isActive } },
{ new: true },
);
if (session) {
query.session(session);
}
return await query;
}
return {
findConfigByPrincipal,
getApplicableConfigs,
upsertConfig,
deleteConfig,
toggleConfigActive,
};
}
export type ConfigMethods = ReturnType<typeof createConfigMethods>;

View file

@ -19,6 +19,8 @@ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
import { createAclEntryMethods, type AclEntryMethods } from './aclEntry';
import { createShareMethods, type ShareMethods } from './share';
/* Config */
import { createConfigMethods, type ConfigMethods } from './config';
export type AllMethods = UserMethods &
SessionMethods &
@ -33,7 +35,8 @@ export type AllMethods = UserMethods &
AclEntryMethods &
ShareMethods &
AccessRoleMethods &
PluginAuthMethods;
PluginAuthMethods &
ConfigMethods;
/**
* Creates all database methods for all collections
@ -55,6 +58,7 @@ export function createMethods(mongoose: typeof import('mongoose')): AllMethods {
...createAclEntryMethods(mongoose),
...createShareMethods(mongoose),
...createPluginAuthMethods(mongoose),
...createConfigMethods(mongoose),
};
}
@ -73,4 +77,5 @@ export type {
ShareMethods,
AccessRoleMethods,
PluginAuthMethods,
ConfigMethods,
};

View file

@ -241,6 +241,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
* @param params - Parameters object
* @param params.userId - The user ID
* @param params.role - Optional user role (if not provided, will query from DB)
* @param params.includeGroups - Whether to include group principals (default: true)
* @param session - Optional MongoDB session for transactions
* @returns Array of principal objects with type and id
*/
@ -248,10 +249,11 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
params: {
userId: string | Types.ObjectId;
role?: string | null;
includeGroups?: boolean;
},
session?: ClientSession,
): Promise<Array<{ principalType: string; principalId?: string | Types.ObjectId }>> {
const { userId, role } = params;
const { userId, role, includeGroups = true } = params;
/** `userId` must be an `ObjectId` for USER principal since ACL entries store `ObjectId`s */
const userObjectId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId;
const principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }> = [
@ -275,11 +277,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
principals.push({ principalType: PrincipalType.ROLE, principalId: userRole });
}
const userGroups = await getUserGroups(userId, session);
if (userGroups && userGroups.length > 0) {
userGroups.forEach((group) => {
principals.push({ principalType: PrincipalType.GROUP, principalId: group._id });
});
// Optionally include groups
if (includeGroups) {
const userGroups = await getUserGroups(userId, session);
if (userGroups && userGroups.length > 0) {
userGroups.forEach((group) => {
principals.push({ principalType: PrincipalType.GROUP, principalId: group._id });
});
}
}
principals.push({ principalType: PrincipalType.PUBLIC });

View file

@ -0,0 +1,6 @@
import configSchema from '../schema/config';
import type { IConfig } from '~/types';
export function createConfigModel(mongoose: typeof import('mongoose')) {
return mongoose.models.Config || mongoose.model<IConfig>('Config', configSchema);
}

View file

@ -26,6 +26,7 @@ import { createMemoryModel } from './memory';
import { createAccessRoleModel } from './accessRole';
import { createAclEntryModel } from './aclEntry';
import { createGroupModel } from './group';
import { createConfigModel } from './config';
/**
* Creates all database models for all collections
@ -60,5 +61,6 @@ export function createModels(mongoose: typeof import('mongoose')) {
AccessRole: createAccessRoleModel(mongoose),
AclEntry: createAclEntryModel(mongoose),
Group: createGroupModel(mongoose),
Config: createConfigModel(mongoose),
};
}

View file

@ -0,0 +1,58 @@
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: Schema.Types.Mixed, // ObjectId for users/groups, String for roles
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: 1,
},
},
{ timestamps: true },
);
// Composite index for efficient querying
configSchema.index({ principalType: 1, principalId: 1, isActive: 1 });
configSchema.index({ priority: 1, isActive: 1 });
// Auto-increment version on overrides change
configSchema.pre('save', function (next) {
if (this.isModified('overrides')) {
this.configVersion += 1;
}
next();
});
export default configSchema;

View file

@ -0,0 +1,32 @@
import type { Document, Types } from 'mongoose';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { AppConfig } from './app';
/**
* Configuration for a principal (user, group, or role)
*/
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 AppConfig structure */
overrides: Partial<AppConfig>;
/** Whether this config override is currently active */
isActive: boolean;
/** Version number for cache invalidation */
configVersion: number;
/** When this config was created */
createdAt?: Date;
/** When this config was last updated */
updatedAt?: Date;
};
export type IConfig = Config &
Document & {
_id: Types.ObjectId;
};

View file

@ -1,7 +1,7 @@
import type { Types } from 'mongoose';
export type ObjectId = Types.ObjectId;
export * from './app';
export * from './user';
export * from './token';
export * from './convo';
@ -25,6 +25,9 @@ export * from './prompts';
export * from './accessRole';
export * from './aclEntry';
export * from './group';
/* Config */
export * from './app';
export * from './config';
/* Web */
export * from './web';
/* MCP Servers */