mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 00:33:40 +00:00
* fix: prevent assign-only config priority changes * fix: preserve assign-only config priority atomically * style: format config priority guard * fix: type preserve priority upsert option
834 lines
29 KiB
TypeScript
834 lines
29 KiB
TypeScript
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
|
|
import {
|
|
BASE_ONLY_CONFIG_SECTIONS,
|
|
PrincipalType,
|
|
PrincipalModel,
|
|
INTERFACE_PERMISSION_FIELDS,
|
|
PERMISSION_SUB_KEYS,
|
|
} from 'librechat-data-provider';
|
|
import type { AppConfig, ConfigSection, IConfig, SystemCapability } from '@librechat/data-schemas';
|
|
import type { TCustomConfig } from 'librechat-data-provider';
|
|
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;
|
|
const BASE_ONLY_OVERRIDE_SECTIONS = new Set<string>(BASE_ONLY_CONFIG_SECTIONS);
|
|
|
|
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];
|
|
}
|
|
|
|
function isBaseOnlyFieldPath(fieldPath: string): boolean {
|
|
return BASE_ONLY_OVERRIDE_SECTIONS.has(getTopLevelSection(fieldPath));
|
|
}
|
|
|
|
/**
|
|
* Returns true if `fieldPath` targets an interface permission field or permission sub-key.
|
|
*
|
|
* - `"interface.prompts"` → true (boolean permission field)
|
|
* - `"interface.agents.use"` → true (permission sub-key)
|
|
* - `"interface.mcpServers"` → true (entire composite field)
|
|
* - `"interface.mcpServers.use"` → true (permission sub-key)
|
|
* - `"interface.mcpServers.placeholder"` → false (UI-only sub-key)
|
|
* - `"interface.peoplePicker.users"` → true (all peoplePicker sub-keys are permissions)
|
|
* - `"interface.modelSelect"` → false (UI-only field)
|
|
*/
|
|
function isInterfacePermissionPath(fieldPath: string): boolean {
|
|
const parts = fieldPath.split('.');
|
|
if (parts[0] !== 'interface' || parts.length < 2) {
|
|
return false;
|
|
}
|
|
if (!INTERFACE_PERMISSION_FIELDS.has(parts[1])) {
|
|
return false;
|
|
}
|
|
// "interface.<permField>" with no sub-key → permission (blocks the whole field)
|
|
if (parts.length === 2) {
|
|
return true;
|
|
}
|
|
// "interface.<permField>.<subKey>" → only block if sub-key is a permission bit
|
|
return PERMISSION_SUB_KEYS.has(parts[2]);
|
|
}
|
|
|
|
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,
|
|
options?: { expectEmpty?: boolean; preservePriority?: boolean },
|
|
) => Promise<IConfig | null>;
|
|
patchConfigFields: (
|
|
principalType: PrincipalType,
|
|
principalId: string | Types.ObjectId,
|
|
principalModel: PrincipalModel,
|
|
fields: Record<string, unknown>,
|
|
priority: number,
|
|
session?: ClientSession,
|
|
) => Promise<IConfig | null>;
|
|
tombstoneConfigField: (
|
|
principalType: PrincipalType,
|
|
principalId: string | Types.ObjectId,
|
|
principalModel: PrincipalModel,
|
|
fieldPath: string,
|
|
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,
|
|
options?: { expectEmpty?: boolean },
|
|
) => Promise<IConfig | null>;
|
|
toggleConfigActive: (
|
|
principalType: PrincipalType,
|
|
principalId: string | Types.ObjectId,
|
|
isActive: boolean,
|
|
session?: ClientSession,
|
|
options?: { expectEmpty?: boolean },
|
|
) => Promise<IConfig | null>;
|
|
hasConfigCapability: (
|
|
user: CapabilityUser,
|
|
section: ConfigSection | null,
|
|
verb?: 'manage' | 'read',
|
|
) => Promise<boolean>;
|
|
hasCapability?: (user: CapabilityUser, capability: SystemCapability) => Promise<boolean>;
|
|
getAppConfig?: (options?: {
|
|
role?: string;
|
|
userId?: string;
|
|
tenantId?: string;
|
|
baseOnly?: boolean;
|
|
}) => Promise<AppConfig>;
|
|
/** Invalidate all config-related caches after a mutation. */
|
|
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
|
|
}
|
|
|
|
// ── 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): {
|
|
listConfigs: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
getBaseConfig: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
getConfig: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
upsertConfigOverrides: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
patchConfigField: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
tombstoneConfigField: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
deleteConfigField: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
deleteConfigOverrides: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
toggleConfig: (req: ServerRequest, res: Response) => Promise<Response>;
|
|
} {
|
|
const {
|
|
listAllConfigs,
|
|
findConfigByPrincipal,
|
|
upsertConfig,
|
|
patchConfigFields,
|
|
tombstoneConfigField: writeConfigTombstone,
|
|
unsetConfigField,
|
|
deleteConfig,
|
|
toggleConfigActive,
|
|
hasConfigCapability,
|
|
hasCapability = async () => false,
|
|
getAppConfig,
|
|
invalidateConfigCaches,
|
|
} = deps;
|
|
|
|
/**
|
|
* GET / — List all active config overrides.
|
|
*/
|
|
async function listConfigs(req: ServerRequest, res: Response): Promise<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): Promise<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 baseOnly = (req.query as Record<string, unknown>).baseOnly === 'true';
|
|
const appConfig = await getAppConfig({
|
|
tenantId: user.tenantId,
|
|
baseOnly,
|
|
});
|
|
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): Promise<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): Promise<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' });
|
|
}
|
|
|
|
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
|
|
|
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const hasAssignConfigs =
|
|
hasBroadManage ||
|
|
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
|
|
|
if (!hasAssignConfigs) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const filteredOverrides = {
|
|
...(overrides as Record<string, unknown>),
|
|
} as Partial<TCustomConfig>;
|
|
for (const section of BASE_ONLY_OVERRIDE_SECTIONS) {
|
|
if (section in filteredOverrides) {
|
|
delete (filteredOverrides as Record<string, unknown>)[section];
|
|
logger.warn(
|
|
`[adminConfig] Stripping base-only config section "${section}" - configure it in librechat.yaml instead`,
|
|
);
|
|
}
|
|
}
|
|
const iface = (overrides as Record<string, unknown>).interface;
|
|
if (iface != null && typeof iface === 'object' && !Array.isArray(iface)) {
|
|
const filteredIface: Record<string, unknown> = {};
|
|
for (const [field, val] of Object.entries(iface as Record<string, unknown>)) {
|
|
if (!INTERFACE_PERMISSION_FIELDS.has(field)) {
|
|
filteredIface[field] = val;
|
|
} else if (val != null && typeof val === 'object' && !Array.isArray(val)) {
|
|
// Composite permission field (e.g. mcpServers): strip permission
|
|
// sub-keys but preserve UI-only sub-keys like placeholder/trustCheckbox.
|
|
const uiOnly: Record<string, unknown> = {};
|
|
for (const [sub, subVal] of Object.entries(val as Record<string, unknown>)) {
|
|
if (!PERMISSION_SUB_KEYS.has(sub)) {
|
|
uiOnly[sub] = subVal;
|
|
} else {
|
|
logger.warn(
|
|
`[adminConfig] Stripping interface permission sub-field "${field}.${sub}" — use role permissions instead`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(uiOnly).length > 0) {
|
|
filteredIface[field] = uiOnly;
|
|
}
|
|
} else {
|
|
logger.warn(
|
|
`[adminConfig] Stripping interface permission field "${field}" — use role permissions instead`,
|
|
);
|
|
}
|
|
}
|
|
if (Object.keys(filteredIface).length > 0) {
|
|
(filteredOverrides as Record<string, unknown>).interface = filteredIface;
|
|
} else {
|
|
delete (filteredOverrides as Record<string, unknown>).interface;
|
|
}
|
|
}
|
|
|
|
const overrideSections = Object.keys(filteredOverrides);
|
|
|
|
if (overrideSections.length === 0 && priority == null) {
|
|
return res.status(200).json({ message: 'No actionable override sections provided' });
|
|
}
|
|
|
|
if (overrideSections.length > 0 && !hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
if (priority != null && !hasBroadManage) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring caller-supplied priority on assign-only scope lifecycle upsert to ${principalType}/${principalId}: only broad manage:configs may modify document priority`,
|
|
);
|
|
}
|
|
|
|
const requestedPriority = hasBroadManage ? (priority ?? DEFAULT_PRIORITY) : DEFAULT_PRIORITY;
|
|
const upsertOptions = hasBroadManage
|
|
? { expectEmpty: false }
|
|
: { expectEmpty: true, preservePriority: true };
|
|
|
|
const config = await upsertConfig(
|
|
principalType,
|
|
principalId,
|
|
principalModel(principalType),
|
|
filteredOverrides,
|
|
requestedPriority,
|
|
undefined,
|
|
upsertOptions,
|
|
);
|
|
if (!config && !hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after upsert:', err),
|
|
);
|
|
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): Promise<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' });
|
|
}
|
|
|
|
const validEntries = entries.filter((entry) => {
|
|
if (isBaseOnlyFieldPath(entry.fieldPath)) {
|
|
logger.warn(
|
|
`[adminConfig] Stripping base-only config field "${entry.fieldPath}" - configure it in librechat.yaml instead`,
|
|
);
|
|
return false;
|
|
}
|
|
if (isInterfacePermissionPath(entry.fieldPath)) {
|
|
logger.warn(
|
|
`[adminConfig] Stripping interface permission field "${entry.fieldPath}" — use role permissions instead`,
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
|
|
|
if (validEntries.length === 0) {
|
|
if (!hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
return res.status(200).json({ message: 'No actionable field entries provided' });
|
|
}
|
|
|
|
if (!hasBroadManage) {
|
|
const sections = [...new Set(validEntries.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 validEntries) {
|
|
if (seen.has(entry.fieldPath)) {
|
|
return res.status(400).json({ error: `Duplicate fieldPath: ${entry.fieldPath}` });
|
|
}
|
|
seen.add(entry.fieldPath);
|
|
fields[entry.fieldPath] = entry.value;
|
|
}
|
|
|
|
if (priority != null && !hasBroadManage) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring caller-supplied priority on section-scoped patch to ${principalType}/${principalId}: only broad manage:configs may modify document priority`,
|
|
);
|
|
}
|
|
const requestedPriority = hasBroadManage ? priority : undefined;
|
|
|
|
const existing =
|
|
requestedPriority == null
|
|
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
|
|
: null;
|
|
|
|
const config = await patchConfigFields(
|
|
principalType,
|
|
principalId,
|
|
principalModel(principalType),
|
|
fields,
|
|
requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY,
|
|
);
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after patch:', err),
|
|
);
|
|
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' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /:principalType/:principalId/fields/tombstone — Suppress an inherited config path.
|
|
*/
|
|
async function tombstoneConfigField(req: ServerRequest, res: Response): Promise<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, priority } = req.body as {
|
|
fieldPath?: string;
|
|
priority?: number;
|
|
};
|
|
|
|
if (!fieldPath || typeof fieldPath !== 'string') {
|
|
return res.status(400).json({ error: 'fieldPath is required' });
|
|
}
|
|
|
|
if (priority != null && (typeof priority !== 'number' || priority < 0)) {
|
|
return res.status(400).json({ error: 'priority must be a non-negative number' });
|
|
}
|
|
|
|
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);
|
|
|
|
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
|
if (
|
|
!hasBroadManage &&
|
|
!(await hasConfigCapability(user, section as ConfigSection, 'manage'))
|
|
) {
|
|
return res.status(403).json({
|
|
error: `Insufficient permissions for config section: ${section}`,
|
|
});
|
|
}
|
|
|
|
if (isInterfacePermissionPath(fieldPath)) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring tombstone for interface permission field "${fieldPath}" — use role permissions instead`,
|
|
);
|
|
return res.status(200).json({ message: 'No actionable field path provided' });
|
|
}
|
|
|
|
if (priority != null && !hasBroadManage) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring caller-supplied priority on section-scoped tombstone for ${principalType}/${principalId}: only broad manage:configs may modify document priority`,
|
|
);
|
|
}
|
|
const requestedPriority = hasBroadManage ? priority : undefined;
|
|
|
|
const existing =
|
|
requestedPriority == null
|
|
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
|
|
: null;
|
|
|
|
const config = await writeConfigTombstone(
|
|
principalType,
|
|
principalId,
|
|
principalModel(principalType),
|
|
fieldPath,
|
|
requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY,
|
|
);
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after field tombstone:', err),
|
|
);
|
|
return res.status(200).json({ config });
|
|
} catch (error) {
|
|
logger.error('[adminConfig] tombstoneConfigField error:', error);
|
|
return res.status(500).json({ error: 'Failed to tombstone config field' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /:principalType/:principalId/fields?fieldPath=dotted.path
|
|
*/
|
|
async function deleteConfigField(req: ServerRequest, res: Response): Promise<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}`,
|
|
});
|
|
}
|
|
|
|
if (isBaseOnlyFieldPath(fieldPath)) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring delete for base-only config field "${fieldPath}" - configure it in librechat.yaml instead`,
|
|
);
|
|
return res.status(200).json({ message: 'No actionable field path provided' });
|
|
}
|
|
|
|
if (isInterfacePermissionPath(fieldPath)) {
|
|
logger.warn(
|
|
`[adminConfig] Ignoring delete for interface permission field "${fieldPath}" — use role permissions instead`,
|
|
);
|
|
return res.status(200).json({ message: 'No actionable field path provided' });
|
|
}
|
|
|
|
const config = await unsetConfigField(principalType, principalId, fieldPath);
|
|
if (!config) {
|
|
return res.status(404).json({ error: 'Config not found' });
|
|
}
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after field delete:', err),
|
|
);
|
|
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): Promise<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' });
|
|
}
|
|
|
|
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
|
|
|
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const allowed =
|
|
hasBroadManage ||
|
|
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
|
if (!allowed) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const config = await deleteConfig(principalType, principalId, undefined, {
|
|
expectEmpty: !hasBroadManage,
|
|
});
|
|
if (!config) {
|
|
if (!hasBroadManage) {
|
|
const exists = await findConfigByPrincipal(principalType, principalId, {
|
|
includeInactive: true,
|
|
});
|
|
if (exists) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
}
|
|
return res.status(404).json({ error: 'Config not found' });
|
|
}
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after config delete:', err),
|
|
);
|
|
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): Promise<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' });
|
|
}
|
|
|
|
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
|
|
|
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const allowed =
|
|
hasBroadManage ||
|
|
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
|
if (!allowed) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
const config = await toggleConfigActive(principalType, principalId, isActive, undefined, {
|
|
expectEmpty: !hasBroadManage,
|
|
});
|
|
if (!config) {
|
|
if (!hasBroadManage) {
|
|
const exists = await findConfigByPrincipal(principalType, principalId, {
|
|
includeInactive: true,
|
|
});
|
|
if (exists) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
}
|
|
return res.status(404).json({ error: 'Config not found' });
|
|
}
|
|
|
|
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
|
logger.error('[adminConfig] Cache invalidation failed after toggle:', err),
|
|
);
|
|
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,
|
|
tombstoneConfigField,
|
|
deleteConfigField,
|
|
deleteConfigOverrides,
|
|
toggleConfig,
|
|
};
|
|
}
|