mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 00:33:40 +00:00
WIP: first pass for DB Configs
This commit is contained in:
parent
c9bbb737a5
commit
429f742c85
15 changed files with 765 additions and 44 deletions
258
ROLE_BASED_CONFIG.md
Normal file
258
ROLE_BASED_CONFIG.md
Normal 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
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
51
packages/api/src/config/resolution.ts
Normal file
51
packages/api/src/config/resolution.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
179
packages/data-schemas/src/methods/config.ts
Normal file
179
packages/data-schemas/src/methods/config.ts
Normal 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>;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
6
packages/data-schemas/src/models/config.ts
Normal file
6
packages/data-schemas/src/models/config.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
packages/data-schemas/src/schema/config.ts
Normal file
58
packages/data-schemas/src/schema/config.ts
Normal 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;
|
||||
32
packages/data-schemas/src/types/config.ts
Normal file
32
packages/data-schemas/src/types/config.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue