diff --git a/e2e/specs/mock/scenarios/config-override-validation.spec.ts b/e2e/specs/mock/scenarios/config-override-validation.spec.ts new file mode 100644 index 00000000000..07be48b6767 --- /dev/null +++ b/e2e/specs/mock/scenarios/config-override-validation.spec.ts @@ -0,0 +1,274 @@ +import { expect, test } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { getPrimaryE2EUser } from '../../../setup/users.mock'; +import { withMongo } from '../db'; + +/** + * Principal config overrides are checked against `configSchema`: an invalid field is + * rejected when written, and one already stored is ignored when merged, so the + * `librechat.yaml` value survives. The primary user (first registered, ADMIN) writes + * overrides for a user this file registers, whose own `/api/config` shows the merged result. + * `interface.contextCost` is `true` in e2e/config/librechat.e2e.yaml. + */ + +type Session = { headers: Record; userId: string }; +type InterfaceConfig = { contextCost?: unknown; customWelcome?: string }; + +async function login( + request: APIRequestContext, + user: { email: string; password: string }, +): Promise { + const res = await request.post('/api/auth/login', { + data: { email: user.email, password: user.password }, + }); + expect(res.ok()).toBeTruthy(); + const { token, user: body } = (await res.json()) as { + token: string; + user: { id?: string; _id?: string }; + }; + const userId = body.id ?? body._id; + expect(token).toBeTruthy(); + expect(userId).toBeTruthy(); + return { headers: { Authorization: `Bearer ${token}` }, userId: userId as string }; +} + +/** A user owned by this file, so no other spec's cleanup can remove it mid-run. */ +const targetUser = { + email: `config-override-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`, + name: 'Config Override Target', + password: 'securepassword789', +}; + +let cachedSessions: { admin: Session; target: Session } | undefined; + +/** Logs in once per worker: the harness allows 20 logins per window across all specs. */ +async function sessions(request: APIRequestContext): Promise<{ admin: Session; target: Session }> { + if (!cachedSessions) { + const admin = await login(request, getPrimaryE2EUser()); + const target = await login(request, targetUser); + cachedSessions = { admin, target }; + } + return cachedSessions; +} + +function configPath(userId: string): string { + return `/api/admin/config/user/${userId}`; +} + +async function readInterface( + request: APIRequestContext, + session: Session, +): Promise { + const res = await request.get('/api/config', { headers: session.headers }); + expect(res.ok()).toBeTruthy(); + const body = (await res.json()) as { interface?: InterfaceConfig }; + return body.interface ?? {}; +} + +async function storedOverrides( + request: APIRequestContext, + admin: Session, + userId: string, +): Promise | null> { + const res = await request.get(configPath(userId), { headers: admin.headers }); + if (res.status() === 404) { + return null; + } + expect(res.ok()).toBeTruthy(); + const body = (await res.json()) as { config?: { overrides?: Record } }; + return body.config?.overrides ?? {}; +} + +async function clearOverrides( + request: APIRequestContext, + admin: Session, + userId: string, +): Promise { + const res = await request.delete(configPath(userId), { headers: admin.headers }); + expect([200, 204, 404]).toContain(res.status()); +} + +test.describe('Principal config override validation', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async ({ request }) => { + const res = await request.post('/api/auth/register', { + data: { ...targetUser, confirm_password: targetUser.password }, + }); + expect(res.ok()).toBeTruthy(); + }); + + test.afterAll(async () => { + await withMongo(async (db) => { + const user = await db.collection('users').findOne({ email: targetUser.email }); + if (!user) { + return; + } + await db.collection('configs').deleteMany({ principalId: user._id.toString() }); + await db.collection('users').deleteOne({ _id: user._id }); + }); + }); + + test('an invalid field in a whole-document write is rejected and nothing is stored @scenario:config-override-invalid-put-rejected', async ({ + request, + }) => { + const { admin, target } = await sessions(request); + await clearOverrides(request, admin, target.userId); + try { + const res = await request.put(configPath(target.userId), { + headers: admin.headers, + data: { overrides: { interface: { contextCost: 'yes', customWelcome: 'rejected' } } }, + }); + expect(res.status()).toBe(400); + const body = (await res.json()) as { + code: string; + issues: Array<{ path: string; code: string }>; + }; + expect(body.code).toBe('CONFIG_OVERRIDE_INVALID'); + expect(body.issues).toEqual([{ path: 'interface.contextCost', code: 'invalid_type' }]); + + expect(await storedOverrides(request, admin, target.userId)).toBeNull(); + const iface = await readInterface(request, target); + expect(iface.contextCost).toBe(true); + expect(iface.customWelcome).not.toBe('rejected'); + } finally { + await clearOverrides(request, admin, target.userId); + } + }); + + test('an invalid field in a field patch is rejected and the stored override is unchanged @scenario:config-override-invalid-patch-rejected', async ({ + request, + }) => { + const { admin, target } = await sessions(request); + await clearOverrides(request, admin, target.userId); + try { + const seeded = await request.put(configPath(target.userId), { + headers: admin.headers, + data: { overrides: { interface: { customWelcome: 'kept' } } }, + }); + expect(seeded.ok()).toBeTruthy(); + + const res = await request.patch(`${configPath(target.userId)}/fields`, { + headers: admin.headers, + data: { + entries: [ + { fieldPath: 'interface.customWelcome', value: 'replaced' }, + { fieldPath: 'interface.contextCost', value: 'yes' }, + ], + }, + }); + expect(res.status()).toBe(400); + const body = (await res.json()) as { issues: Array<{ path: string }> }; + expect(body.issues.map((issue) => issue.path)).toEqual(['interface.contextCost']); + + expect(await storedOverrides(request, admin, target.userId)).toEqual({ + interface: { customWelcome: 'kept' }, + }); + } finally { + await clearOverrides(request, admin, target.userId); + } + }); + + test('a valid partial section override is accepted and merged over the base @scenario:config-override-valid-partial-accepted', async ({ + request, + }) => { + const { admin, target } = await sessions(request); + await clearOverrides(request, admin, target.userId); + const marker = `welcome-${Date.now()}`; + try { + const res = await request.put(configPath(target.userId), { + headers: admin.headers, + data: { overrides: { interface: { customWelcome: marker } } }, + }); + expect(res.ok()).toBeTruthy(); + + await expect + .poll(async () => (await readInterface(request, target)).customWelcome, { + timeout: 30000, + intervals: [500, 1000, 2000], + }) + .toBe(marker); + expect((await readInterface(request, target)).contextCost).toBe(true); + } finally { + await clearOverrides(request, admin, target.userId); + } + }); + + test('an invalid override already stored leaves the base value in place @scenario:config-override-stored-invalid-keeps-base', async ({ + request, + }) => { + const { admin, target } = await sessions(request); + await clearOverrides(request, admin, target.userId); + const marker = `stored-${Date.now()}`; + try { + const seeded = await request.put(configPath(target.userId), { + headers: admin.headers, + data: { overrides: { interface: { customWelcome: 'seed' } } }, + }); + expect(seeded.ok()).toBeTruthy(); + + /** Written straight to the collection, as a document stored before write validation. */ + const written = await withMongo((db) => + db + .collection('configs') + .updateOne( + { principalType: 'user', principalId: target.userId }, + { $set: { 'overrides.interface.contextCost': 'yes' } }, + ), + ); + expect(written.matchedCount).toBe(1); + + /** A valid patch on the same document invalidates the merged-config cache. */ + const patched = await request.patch(`${configPath(target.userId)}/fields`, { + headers: admin.headers, + data: { entries: [{ fieldPath: 'interface.customWelcome', value: marker }] }, + }); + expect(patched.ok()).toBeTruthy(); + + await expect + .poll(async () => (await readInterface(request, target)).customWelcome, { + timeout: 30000, + intervals: [500, 1000, 2000], + }) + .toBe(marker); + expect((await readInterface(request, target)).contextCost).toBe(true); + } finally { + await clearOverrides(request, admin, target.userId); + } + }); + + test('a lower-priority override completed by a higher-priority one keeps both @scenario:config-override-layers-complete-each-other', async ({ + request, + }) => { + const { admin, target } = await sessions(request); + const rolePath = '/api/admin/config/role/USER'; + await clearOverrides(request, admin, target.userId); + try { + /** The role layer leaves out the required `siteKey`, which the user layer supplies. */ + const role = await request.put(rolePath, { + headers: admin.headers, + data: { priority: 10, overrides: { turnstile: { options: { size: 'compact' } } } }, + }); + expect(role.ok()).toBeTruthy(); + const user = await request.put(configPath(target.userId), { + headers: admin.headers, + data: { priority: 20, overrides: { turnstile: { siteKey: 'layered-site-key' } } }, + }); + expect(user.ok()).toBeTruthy(); + + await expect + .poll( + async () => { + const res = await request.get('/api/config', { headers: target.headers }); + expect(res.ok()).toBeTruthy(); + return ((await res.json()) as { turnstile?: unknown }).turnstile; + }, + { timeout: 30000, intervals: [500, 1000, 2000] }, + ) + .toEqual({ siteKey: 'layered-site-key', options: { size: 'compact' } }); + } finally { + await request.delete(rolePath, { headers: admin.headers }); + await clearOverrides(request, admin, target.userId); + } + }); +}); diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 11c16c4e93f..b2168470b72 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -1455,7 +1455,8 @@ describe('createAdminConfigHandlers', () => { expect(res.statusCode).toBe(200); const [, , , , priorityArg] = deps.patchConfigFields.mock.calls[0]; expect(priorityArg).toBe(999); - expect(deps.findConfigByPrincipal).not.toHaveBeenCalled(); + /** Read once to validate the write on top of the stored fields, not for its priority. */ + expect(deps.findConfigByPrincipal).toHaveBeenCalledTimes(1); }); it('preserves priority 0 when broad caller supplies it', async () => { @@ -2434,4 +2435,146 @@ describe('createAdminConfigHandlers', () => { } }); }); + + describe('override validation against configSchema', () => { + it('rejects a whole-document write with an invalid field and stores nothing', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { registration: { oauthStateTtlMs: 5 }, interface: { modelSelect: false } }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Invalid config override', + code: 'CONFIG_OVERRIDE_INVALID', + issues: [{ path: 'registration.oauthStateTtlMs', code: 'too_small' }], + }); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('validates a whole-document write on the base its retained tombstones leave', async () => { + const base = { + config: { + cloudfront: { domain: 'https://cdn.example.com', imageSigning: 'cookies' }, + }, + }; + const body = { overrides: { cloudfront: { requireSignedAccess: true } } }; + const params = { principalType: 'role', principalId: 'admin' }; + + const kept = createHandlers({ getAppConfig: jest.fn().mockResolvedValue(base) }); + const keptRes = mockRes(); + await kept.handlers.upsertConfigOverrides(mockReq({ params, body }), keptRes); + expect(keptRes.statusCode).toBe(201); + + const tombstoned = createHandlers({ + getAppConfig: jest.fn().mockResolvedValue(base), + findConfigByPrincipal: jest + .fn() + .mockResolvedValue({ overrides: {}, tombstones: ['cloudfront.imageSigning'] }), + }); + const tombstonedRes = mockRes(); + await tombstoned.handlers.upsertConfigOverrides(mockReq({ params, body }), tombstonedRes); + expect(tombstonedRes.statusCode).toBe(400); + expect(tombstonedRes.body?.issues).toEqual([ + expect.objectContaining({ path: 'cloudfront.requireSignedAccess' }), + ]); + expect(tombstoned.deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('accepts a partial section whose provided fields are valid', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + registration: { oauthStateTtlMs: 120_000 }, + mcpServers: { github: { timeout: 5000 } }, + endpoints: { custom: [{ name: 'groq', apiKey: 'sk-test' }] }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + expect(deps.upsertConfig).toHaveBeenCalledTimes(1); + }); + + it('rejects a field patch with an invalid value and writes no entry', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { fieldPath: 'interface.customWelcome', value: 'hi' }, + { fieldPath: 'registration.oauthStateTtlMs', value: 'soon' }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body?.issues).toEqual([ + expect.objectContaining({ path: 'registration.oauthStateTtlMs' }), + ]); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('validates an object patch as a partial of the addressed section', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'interface.schedules', value: { maxPerUser: 'x' } }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body?.issues).toEqual([ + expect.objectContaining({ path: 'interface.schedules.maxPerUser' }), + ]); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('rejects a patch that addresses a custom endpoint by index', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'endpoints.custom.0.models', value: { default: ['m'] } }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body?.issues).toEqual([ + { path: 'endpoints.custom', code: 'indexed_merge_key_write' }, + ]); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('accepts a secret field cleared with a non-string value', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { entries: [{ fieldPath: 'ocr.apiKey', value: null }] }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.patchConfigFields).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index ef30bbad4e8..ddeaffe0e16 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -1,4 +1,10 @@ -import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas'; +import { + logger, + getConfigFieldIssues, + applyConfigTombstones, + getConfigOverrideIssues, + BASE_CONFIG_PRINCIPAL_ID, +} from '@librechat/data-schemas'; import { BASE_PRINCIPAL_CONFIG_SECTIONS, BASE_ONLY_CONFIG_SECTIONS, @@ -11,7 +17,13 @@ import { isProcessMCPServerConfig, isProcessMCPServerField, } from 'librechat-data-provider'; -import type { AppConfig, ConfigSection, IConfig, SystemCapability } from '@librechat/data-schemas'; +import type { + AppConfig, + ConfigSection, + IConfig, + SystemCapability, + ConfigOverrideIssue, +} from '@librechat/data-schemas'; import type { TCustomConfig } from 'librechat-data-provider'; import type { Types, ClientSession } from 'mongoose'; import type { Response } from 'express'; @@ -411,6 +423,15 @@ function redactAppConfigForResponse(appConfig: AppConfig): AppConfig { return safeConfig; } +/** Reports only the paths and stable codes: schema messages can echo the submitted values. */ +function invalidOverrideResponse(res: Response, issues: ConfigOverrideIssue[]): Response { + return res.status(400).json({ + error: 'Invalid config override', + code: 'CONFIG_OVERRIDE_INVALID', + issues: issues.map(({ path, code }) => ({ path, code })), + }); +} + function preservePatchedConfigSecretFields( fields: Record, existingOverrides?: unknown, @@ -462,6 +483,15 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { invalidateConfigCaches, } = deps; + /** The deployment's `librechat.yaml` config, which overrides are validated on top of. */ + async function getBaseYamlConfig(tenantId?: string): Promise> { + if (!getAppConfig) { + return {}; + } + const appConfig = await getAppConfig({ tenantId, baseOnly: true }); + return appConfig?.config ?? {}; + } + /** * GET / — List all active config overrides. */ @@ -724,10 +754,22 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { const needsProtectedBaseSections = principalId === BASE_CONFIG_PRINCIPAL_ID && (overrideSections.length > 0 || priority != null); - const existingConfig = - needsExistingSecrets || needsProtectedBaseSections - ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) - : null; + const needsExisting = needsExistingSecrets || needsProtectedBaseSections; + const [stored, baseYaml] = await Promise.all([ + overrideSections.length > 0 || needsExisting + ? findConfigByPrincipal(principalType, principalId, { includeInactive: true }) + : null, + overrideSections.length > 0 ? getBaseYamlConfig(user.tenantId) : {}, + ]); + /** A full replace keeps the principal's tombstones, so they shape the base it lands on. */ + const overrideIssues = getConfigOverrideIssues( + encryptedOverrides, + applyConfigTombstones(baseYaml, stored?.tombstones), + ); + if (overrideIssues.length > 0) { + return invalidOverrideResponse(res, overrideIssues); + } + const existingConfig = needsExisting ? stored : null; const preservedOverrides = preserveConfigSecrets( encryptedOverrides, existingConfig?.overrides, @@ -903,14 +945,15 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { } const requestedPriority = hasBroadManage ? priority : undefined; - const hasObjectValuedSecretPatch = Object.entries(fields).some(([fieldPath, value]) => - isConfigSecretPreservablePatch(fieldPath, value), - ); - const existing = - requestedPriority == null || hasObjectValuedSecretPatch - ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) - : null; + const [existing, baseYaml] = await Promise.all([ + findConfigByPrincipal(principalType, principalId, { includeInactive: true }), + getBaseYamlConfig(user.tenantId), + ]); const encryptedFields = encryptConfigSecretFields(fields); + const fieldIssues = getConfigFieldIssues(encryptedFields, baseYaml, existing); + if (fieldIssues.length > 0) { + return invalidOverrideResponse(res, fieldIssues); + } const preservedFields = preservePatchedConfigSecretFields( encryptedFields, existing?.overrides, diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 16f9306daed..ee813ed8d21 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -613,7 +613,7 @@ describe('createAppConfigService', () => { getApplicableConfigs: jest.fn().mockResolvedValue([ { priority: 10, - overrides: { endpoints: ['untrusted-override'] }, + overrides: { interface: { modelSelect: false } }, isActive: true, }, ]), @@ -625,7 +625,7 @@ describe('createAppConfigService', () => { expect(config).toEqual( expect.objectContaining({ - endpoints: ['untrusted-override'], + interfaceConfig: { modelSelect: false }, }), ); }); diff --git a/packages/api/src/endpoints/config/endpoints.spec.ts b/packages/api/src/endpoints/config/endpoints.spec.ts index bee644dfd02..2c0446ad024 100644 --- a/packages/api/src/endpoints/config/endpoints.spec.ts +++ b/packages/api/src/endpoints/config/endpoints.spec.ts @@ -453,7 +453,7 @@ describe('createEndpointsConfigService', () => { name: 'FOO', apiKey: '${FOO_KEY}', baseURL: '${FOO_URL}', - models: { fetch: true }, + models: { default: ['foo-model'], fetch: true }, }, ], }, diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index 594f87b6799..533ff4b12f3 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -1,7 +1,13 @@ import { INTERFACE_PERMISSION_FIELDS, PermissionTypes } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig, IConfig } from '~/types'; +import { + mergeConfigOverrides, + getConfigFieldIssues, + applyConfigTombstones, + getConfigOverrideIssues, +} from './resolution'; import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; -import { mergeConfigOverrides } from './resolution'; function fakeConfig( overrides: Record, @@ -235,9 +241,12 @@ describe('mergeConfigOverrides', () => { }); it('replaces plain arrays (no merge key) instead of concatenating', () => { - const configs = [fakeConfig({ endpoints: ['anthropic', 'google'] }, 10)]; - const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; - expect(result.endpoints).toEqual(['anthropic', 'google']); + const base = { registration: { allowedDomains: ['base.com'] } } as unknown as AppConfig; + const configs = [fakeConfig({ registration: { allowedDomains: ['a.com', 'b.com'] } }, 10)]; + const result = mergeConfigOverrides(base, configs) as unknown as { + registration: { allowedDomains: string[] }; + }; + expect(result.registration.allowedDomains).toEqual(['a.com', 'b.com']); }); it('merges endpoints.custom arrays by name instead of replacing', () => { @@ -422,11 +431,11 @@ describe('mergeConfigOverrides', () => { expect(baseConfig).toEqual(original); }); - it('handles null override values', () => { + it('keeps the base value under a null override the schema does not allow', () => { const configs = [fakeConfig({ interface: { modelSelect: null } }, 10)]; const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record; const iface = result.interfaceConfig as Record; - expect(iface.modelSelect).toBeNull(); + expect(iface.modelSelect).toBe(true); }); it('skips configs with no overrides object', () => { @@ -880,6 +889,219 @@ describe('mergeConfigOverrides', () => { }); }); +describe('mergeConfigOverrides: filtered MCP servers', () => { + it('does not let a filtered process-backed server shape a later partial', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig({ mcpServers: { injected: { type: 'stdio', command: 'node', args: ['x'] } } }, 10), + fakeConfig({ mcpServers: { injected: { title: 'Injected' } } }, 20, undefined, 'other'), + ]) as unknown as { mcpConfig?: Record }; + + expect(merged.mcpConfig?.injected).toEqual({ title: 'Injected' }); + }); +}); + +describe('mergeConfigOverrides: invalid stored overrides', () => { + const base = { + interfaceConfig: { contextCost: true, customWelcome: 'base' }, + registration: { oauthStateTtlMs: 600_000, allowedDomains: ['base.com'] }, + endpoints: { + custom: [{ name: 'groq', baseURL: 'https://base', apiKey: 'k', models: { default: ['m'] } }], + }, + } as unknown as AppConfig; + + it('keeps the base value when a stored override field fails the schema', () => { + const merged = mergeConfigOverrides(base, [ + fakeConfig( + { + interface: { contextCost: 'yes', customWelcome: 'override' }, + registration: { oauthStateTtlMs: 5, allowedDomains: ['override.com'] }, + }, + 10, + ), + ]) as unknown as Record>; + + expect(merged.interfaceConfig).toEqual({ contextCost: true, customWelcome: 'override' }); + expect(merged.registration).toEqual({ + oauthStateTtlMs: 600_000, + allowedDomains: ['override.com'], + }); + }); + + it('drops only the invalid field of a merged array item', () => { + const merged = mergeConfigOverrides(base, [ + fakeConfig( + { endpoints: { custom: [{ name: 'groq', baseURL: 'https://o', models: 5 }] } }, + 10, + ), + ]) as unknown as { endpoints: { custom: Array> } }; + + expect(merged.endpoints.custom).toEqual([ + { name: 'groq', baseURL: 'https://o', apiKey: 'k', models: { default: ['m'] } }, + ]); + }); + + it('reports an earlier merged array item that repeats a merge key', () => { + const custom = [ + { name: 'x', models: { default: ['m'] } }, + { name: 'x', baseURL: 5 }, + ]; + expect(getConfigOverrideIssues({ endpoints: { custom } })).toEqual([ + { + path: 'endpoints.custom.0', + segments: ['endpoints', 'custom', '0'], + code: 'duplicate_merge_key', + }, + { + path: 'endpoints.custom.1.baseURL', + segments: ['endpoints', 'custom', '1', 'baseURL'], + code: 'invalid_type', + }, + ]); + + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig({ endpoints: { custom } }, 10), + ]) as unknown as { endpoints: { custom: unknown[] } }; + expect(merged.endpoints.custom).toEqual([{ name: 'x' }]); + }); + + it('drops a key the accepted union option does not define, keeping the rest', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig( + { memory: { agent: { enabled: true, id: 5, provider: 'openAI', model: 'gpt-4o' } } }, + 10, + ), + ]) as unknown as { memory: { agent: Record } }; + + expect(merged.memory.agent).toEqual({ enabled: true, provider: 'openAI', model: 'gpt-4o' }); + }); + + it('drops a merged array item that is not an object', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig({ endpoints: { custom: [null, 'bad', { name: 'kept', baseURL: 'x' }] } }, 10), + ]) as unknown as { endpoints: { custom: unknown[] } }; + + expect(merged.endpoints.custom).toEqual([{ name: 'kept', baseURL: 'x' }]); + }); + + it('drops a merged array item that has no merge key', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig( + { endpoints: { custom: [{ baseURL: 'https://keyless' }, { name: 'kept', baseURL: 'x' }] } }, + 10, + ), + ]) as unknown as { endpoints: { custom: Array> } }; + + expect(merged.endpoints.custom).toEqual([{ name: 'kept', baseURL: 'x' }]); + }); + + it('strips an invalid field under a record key that contains a dot', () => { + const merged = mergeConfigOverrides( + { + config: { mcpServers: { 'team.prod': { url: 'https://mcp', timeout: 1000 } } }, + mcpConfig: { 'team.prod': { url: 'https://mcp', timeout: 1000 } }, + } as unknown as AppConfig, + [fakeConfig({ mcpServers: { 'team.prod': { timeout: 'x', initTimeout: 500 } } }, 10)], + ) as unknown as { mcpConfig: Record> }; + + expect(merged.mcpConfig['team.prod']).toEqual({ + url: 'https://mcp', + timeout: 1000, + initTimeout: 500, + }); + }); + + it('ignores a stored overrides document that is not an object', () => { + const merged = mergeConfigOverrides(base, [ + fakeConfig(['stray'] as unknown as Record, 10), + ]) as unknown as Record; + + expect(merged).toEqual(base); + expect(merged).not.toHaveProperty('0'); + }); + + it('drops a replaced-array item that fails a refinement, keeping its valid siblings', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig( + { + messageFilter: { + pii: { + customPatterns: [ + { id: 'bad', label: 'Bad', regex: '(unclosed' }, + { id: 'ok', label: 'Ok', regex: 'x+' }, + ], + }, + }, + }, + 10, + ), + ]) as unknown as { messageFilter: { pii: { customPatterns: Array<{ id: string }> } } }; + + expect(merged.messageFilter.pii.customPatterns.map((pattern) => pattern.id)).toEqual(['ok']); + }); + + it('drops a replaced array item left incomplete by a repair and keeps other sections', () => { + const merged = mergeConfigOverrides(baseConfig, [ + fakeConfig( + { + interface: { customWelcome: 'kept' }, + endpoints: { + azureOpenAI: { + groups: [{ group: 'g', apiKey: 'k', instanceName: 'i', version: 'v', models: 5 }], + }, + }, + }, + 10, + ), + ]) as unknown as { + interfaceConfig: Record; + endpoints: unknown; + }; + + expect(merged.interfaceConfig.customWelcome).toBe('kept'); + expect(merged.endpoints).toEqual(baseConfig.endpoints); + }); + + it('keeps a lower layer that relies on a higher layer for a required field', () => { + const merged = mergeConfigOverrides({} as AppConfig, [ + fakeConfig( + { + cloudfront: { + imageSigning: 'cookies', + cookieDomain: '.example.com', + requireSignedAccess: true, + }, + }, + 10, + ), + fakeConfig({ cloudfront: { domain: 'https://cdn.example.com' } }, 20, undefined, 'other'), + ]) as unknown as { cloudfront: Record }; + + expect(merged.cloudfront).toEqual({ + imageSigning: 'cookies', + cookieDomain: '.example.com', + requireSignedAccess: true, + domain: 'https://cdn.example.com', + }); + }); + + it('lets a lower-priority valid override survive a higher-priority invalid one', () => { + const merged = mergeConfigOverrides(base, [ + fakeConfig({ interface: { contextCost: false } }, 10), + fakeConfig({ interface: { contextCost: 'no' } }, 20, undefined, 'other'), + ]) as unknown as { interfaceConfig: Record }; + + expect(merged.interfaceConfig.contextCost).toBe(false); + }); + + it('leaves fields the schema does not define untouched', () => { + const merged = mergeConfigOverrides(base, [ + fakeConfig({ registration: { enabled: false } }, 10), + ]) as unknown as { registration: Record }; + + expect(merged.registration.enabled).toBe(false); + }); +}); + describe('INTERFACE_PERMISSION_FIELDS', () => { it('contains all expected permission fields', () => { const expected = [ @@ -914,3 +1136,367 @@ describe('INTERFACE_PERMISSION_FIELDS', () => { } }); }); + +describe('getConfigOverrideIssues', () => { + const paths = (issues: Array<{ path: string }>) => issues.map((issue) => issue.path); + + it('accepts a partial section whose supplied fields are valid', () => { + expect( + getConfigOverrideIssues({ + registration: { allowedDomains: ['a.com'] }, + interface: { schedules: { maxPerUser: 2 } }, + mcpServers: { github: { timeout: 5000 } }, + }), + ).toEqual([]); + }); + + it('reports each invalid supplied field by its dot-path', () => { + expect( + paths( + getConfigOverrideIssues({ + registration: { oauthStateTtlMs: 5 }, + balance: { enabled: 'yes' }, + interface: { contextCost: null }, + }), + ), + ).toEqual(['registration.oauthStateTtlMs', 'balance.enabled', 'interface.contextCost']); + }); + + it('judges a union by the branch the value was written for', () => { + expect(paths(getConfigOverrideIssues({ memory: { agent: { id: 5 } } }))).toEqual([ + 'memory.agent.id', + ]); + expect( + paths(getConfigOverrideIssues({ memory: { agent: { id: 5, provider: 'openAI' } } })), + ).toEqual(['memory.agent.id']); + expect(getConfigOverrideIssues({ memory: { agent: { id: 'agent_1' } } })).toEqual([]); + expect( + paths(getConfigOverrideIssues({ interface: { schedules: { maxPerUser: 'x' } } })), + ).toEqual(['interface.schedules.maxPerUser']); + }); + + it('applies refinements to the values an override supplies', () => { + expect( + paths( + getConfigOverrideIssues({ + messageFilter: { + pii: { customPatterns: [{ id: 'p1', label: 'Bad', regex: '(unclosed' }] }, + }, + }), + ), + ).toEqual(['messageFilter.pii.customPatterns.0.regex']); + expect( + paths( + getConfigOverrideIssues({ + cloudfront: { + domain: 'https://cdn.example.com', + imageSigning: 'none', + requireSignedAccess: true, + }, + }), + ), + ).toEqual(['cloudfront.requireSignedAccess']); + }); + + it('leaves a write that relies on the base for related or required fields to the merge', () => { + const base = { + cloudfront: { + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + }, + } as Partial; + expect(getConfigOverrideIssues({ cloudfront: { requireSignedAccess: true } }, base)).toEqual( + [], + ); + expect(getConfigOverrideIssues({ endpoints: { azureOpenAI: { assistants: true } } })).toEqual( + [], + ); + }); + + it('reports a key another union option defines when the accepted option drops it', () => { + const agent = { enabled: true, id: 5, provider: 'openAI', model: 'gpt-4o' }; + expect(getConfigOverrideIssues({ memory: { agent } })).toEqual([ + { path: 'memory.agent.id', segments: ['memory', 'agent', 'id'], code: 'union_dropped_key' }, + ]); + expect( + getConfigOverrideIssues({ memory: { agent: { provider: 'openAI', model: 'gpt-4o' } } }), + ).toEqual([]); + expect( + getConfigOverrideIssues({ memory: { agent: { id: 'a', provider: 'openAI', unknown: 1 } } }), + ).toEqual([ + { + path: 'memory.agent.provider', + segments: ['memory', 'agent', 'provider'], + code: 'union_dropped_key', + }, + ]); + }); + + it('requires the merge key on custom endpoint items and maps merged items back by it', () => { + expect(getConfigOverrideIssues({ endpoints: { custom: [{ baseURL: 'https://a' }] } })).toEqual([ + { + path: 'endpoints.custom.0', + segments: ['endpoints', 'custom', '0'], + code: 'missing_merge_key', + }, + ]); + const base = { + endpoints: { + custom: [ + { name: 'a', apiKey: 'k', baseURL: 'https://a', models: { default: ['m'] } }, + { name: 'b', apiKey: 'k', baseURL: 'https://b', models: { default: ['m'] } }, + ], + }, + } as Partial; + expect( + paths(getConfigOverrideIssues({ endpoints: { custom: [{ name: 'b', models: 5 }] } }, base)), + ).toEqual(['endpoints.custom.0.models']); + }); + + it('keeps a record key that contains a dot as one segment', () => { + expect( + getConfigOverrideIssues({ mcpServers: { 'team.prod': { timeout: 'x' } } }, { + mcpServers: { 'team.prod': { url: 'https://mcp' } }, + } as Partial), + ).toEqual([expect.objectContaining({ segments: ['mcpServers', 'team.prod', 'timeout'] })]); + }); + + it('accepts keys the schema does not define and stored secret shapes', () => { + expect( + getConfigOverrideIssues({ unknownSection: 5, registration: { enabled: false } }), + ).toEqual([]); + expect( + getConfigOverrideIssues({ + endpoints: { + custom: [ + { + name: 'x', + apiKey: 'v3:enc', + apiKeyPreview: 'sk-...', + baseURL: 'https://x', + models: { default: ['m'] }, + }, + ], + }, + ocr: { apiKey: '' }, + }), + ).toEqual([]); + }); + + it('rejects an overrides document that is not an object', () => { + expect(getConfigOverrideIssues(['stray'])).toEqual([ + { path: '', segments: [], code: 'invalid_document' }, + ]); + }); +}); + +describe('getConfigOverrideIssues: items addressed by id', () => { + it('attributes a refinement that names an array item by its id to that item', () => { + const environment = (id: string, extra: Record = {}) => ({ + id, + name: id, + type: 'managed', + baseURL: 'https://code.example.com', + ...extra, + }); + const issues = getConfigOverrideIssues({ + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + environment('worker-a'), + environment('worker-b', { pairing: { workerId: 'w1', tokenEnv: 'TOKEN' } }), + ], + }, + }, + }, + }); + + expect(issues).toContainEqual( + expect.objectContaining({ + path: 'endpoints.agents.statefulCodeSessions.environments.1.pairing', + code: 'custom', + }), + ); + }); +}); + +describe('applyConfigTombstones', () => { + it('removes tombstoned base paths except the ones a write clears', () => { + const base = { + cloudfront: { domain: 'https://cdn.example.com', imageSigning: 'cookies' }, + } as Partial; + const tombstones = ['cloudfront.imageSigning']; + expect(applyConfigTombstones(base, tombstones)).toEqual({ + cloudfront: { domain: 'https://cdn.example.com' }, + }); + expect(applyConfigTombstones(base, tombstones, new Set(tombstones))).toEqual(base); + expect( + getConfigOverrideIssues( + { cloudfront: { requireSignedAccess: true } }, + applyConfigTombstones(base, tombstones), + ).map((issue) => issue.path), + ).toEqual(['cloudfront.requireSignedAccess']); + }); +}); + +describe('getConfigFieldIssues', () => { + it('checks each written path the way the stored override would hold it', () => { + expect(getConfigFieldIssues({ 'registration.oauthStateTtlMs': 120_000 })).toEqual([]); + expect( + getConfigFieldIssues({ 'registration.oauthStateTtlMs': 5 }).map((issue) => issue.path), + ).toEqual(['registration.oauthStateTtlMs']); + }); + + it('applies a record refinement to a single written key', () => { + const base = { + endpoints: { + azureOpenAI: { + groups: [ + { + group: 'g', + apiKey: 'k', + instanceName: 'i', + version: '2024-02-01', + models: { 'gpt-4o': { deploymentName: 'gpt-4o' } }, + }, + ], + }, + }, + } as Partial; + expect( + getConfigFieldIssues( + { 'endpoints.azureOpenAI.groups': base.endpoints?.azureOpenAI?.groups }, + base, + ), + ).toEqual([]); + expect( + getConfigFieldIssues( + { + 'endpoints.azureOpenAI.groups': [ + { ...base.endpoints?.azureOpenAI?.groups?.[0], addParams: { web_search: 'yes' } }, + ], + }, + base, + ).map((issue) => issue.path), + ).toEqual(['endpoints.azureOpenAI.groups.0.addParams.web_search']); + }); + + it('judges a write together with the fields the principal already overrides', () => { + const cloudfront = { + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + }; + expect( + getConfigFieldIssues( + { 'cloudfront.requireSignedAccess': true }, + {}, + { + overrides: { cloudfront }, + }, + ), + ).toEqual([]); + expect( + getConfigFieldIssues( + { 'cloudfront.requireSignedAccess': true }, + {}, + { + overrides: { cloudfront: { ...cloudfront, imageSigning: 'none' } }, + }, + ).map((issue) => issue.path), + ).toEqual(['cloudfront.requireSignedAccess']); + expect( + getConfigFieldIssues( + { 'interface.customWelcome': 'hi' }, + {}, + { + overrides: { interface: { contextCost: 'yes' } }, + }, + ), + ).toEqual([]); + }); + + it('reports a stored related field the write makes invalid', () => { + expect( + getConfigFieldIssues( + { 'cloudfront.imageSigning': 'none' }, + {}, + { + overrides: { + cloudfront: { + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + requireSignedAccess: true, + }, + }, + }, + ).map((issue) => issue.path), + ).toEqual(['cloudfront.requireSignedAccess']); + }); + + it('validates on a base with the remaining tombstones of the principal applied', () => { + const base = { + cloudfront: { + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + }, + } as Partial; + expect(getConfigFieldIssues({ 'cloudfront.requireSignedAccess': true }, base)).toEqual([]); + expect( + getConfigFieldIssues({ 'cloudfront.requireSignedAccess': true }, base, { + overrides: {}, + tombstones: ['cloudfront.imageSigning'], + }).map((issue) => issue.path), + ).toEqual(['cloudfront.requireSignedAccess']); + expect( + getConfigFieldIssues({ 'cloudfront.imageSigning': 'cookies' }, base, { + overrides: { cloudfront: { requireSignedAccess: true } }, + tombstones: ['cloudfront.imageSigning'], + }), + ).toEqual([]); + }); + + it('rejects a path past a field that holds a value', () => { + expect( + getConfigFieldIssues({ 'interface.contextCost.foo': true }).map((issue) => issue.path), + ).toEqual(['interface.contextCost']); + expect(getConfigFieldIssues({ 'registration.unknownField.foo': 1 })).toEqual([]); + }); + + it('rejects an indexed write into a merged-by-name array', () => { + expect( + getConfigFieldIssues({ 'endpoints.custom.0.models': { default: ['m'] } }).map( + (issue) => issue.path, + ), + ).toEqual(['endpoints.custom']); + }); + + it('rejects an indexed write into a stored merged-by-name array', () => { + const base = { + endpoints: { + custom: [{ name: 'a', apiKey: 'k', baseURL: 'https://a', models: { default: ['m'] } }], + }, + } as unknown as Partial; + const stored = { overrides: { endpoints: { custom: [{ name: 'a', baseURL: 'https://o' }] } } }; + + expect(getConfigFieldIssues({ 'endpoints.custom.0.name': 'b' }, base, stored)).toEqual([ + { + path: 'endpoints.custom', + segments: ['endpoints', 'custom'], + code: 'indexed_merge_key_write', + }, + ]); + expect( + getConfigFieldIssues( + { 'endpoints.custom': [{ name: 'a', baseURL: 'https://p' }] }, + base, + stored, + ), + ).toEqual([]); + }); +}); diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts index 26b7a5b5bb8..1bd85676a66 100644 --- a/packages/data-schemas/src/app/resolution.ts +++ b/packages/data-schemas/src/app/resolution.ts @@ -5,14 +5,18 @@ import { RUNTIME_CONFIG_INTERFACE_FIELDS, PERMISSION_SUB_KEYS, isProcessMCPServerConfig, + configSchema, } from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig, IConfig } from '~/types'; import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; +import { getTombstonePathsToClear } from '~/methods/config'; +import logger from '~/config/winston'; type AnyObject = { [key: string]: unknown }; const MAX_MERGE_DEPTH = 10; +const MAX_STRIP_PASSES = 16; const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); /** Filters are a fail-closed security boundary even during mixed-package rollouts. */ const BASE_ONLY_OVERRIDE_SECTIONS = new Set(['filters', ...BASE_ONLY_CONFIG_SECTIONS]); @@ -238,6 +242,476 @@ function deepMerge(target: T, source: AnyObject, depth = 0, return result as T; } +export type ConfigOverrideIssue = { + /** Dot-path of the override node the issue is attributed to, in YAML (`TCustomConfig`) keys. */ + path: string; + /** The same location as keys, unambiguous when a record key itself contains a dot. */ + segments: string[]; + /** + * A stable, machine-readable reason: a zod issue code (`invalid_type`, `custom`, ...), + * `missing_merge_key`, `duplicate_merge_key`, `indexed_merge_key_write`, `union_dropped_key`, or `invalid_document`. Schema messages are not carried because + * they can echo the submitted values. + */ + code: string; +}; + +type IssuePath = Array; + +function isPlainObject(value: unknown): value is AnyObject { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwn(target: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(target, key); +} + +function toIssue(segments: string[], code: string): ConfigOverrideIssue { + return { path: segments.join('.'), segments, code }; +} + +/** + * The override item an issue path segment names: by the merge key for merged-by-key arrays + * (the merged index differs from the override's; the last item with a key is the one merged), by index, or by `id` for refinements that + * address an item by its identifier. + */ +function findItemIndex( + node: unknown[], + key: string, + keyField: string | undefined, + mergedItem: unknown, +): number { + if (keyField) { + for (let index = node.length - 1; index >= 0; index--) { + const item = node[index]; + if ( + isPlainObject(item) && + isPlainObject(mergedItem) && + item[keyField] === mergedItem[keyField] + ) { + return index; + } + } + return -1; + } + if (/^\d+$/.test(key)) { + return Number(key); + } + return node.findIndex((item) => isPlainObject(item) && item.id === key); +} + +/** + * The override node an issue in the merged config belongs to: the deepest node the + * overrides supply on the issue's path. Items of merged-by-key arrays are matched by their + * key, since the merged index differs from the override's. `undefined` means the overrides + * did not supply anything on the path, so the issue is the base's own. + */ +function attributeIssue( + overrides: AnyObject, + merged: AnyObject, + issuePath: IssuePath, +): string[] | undefined { + const segments: string[] = []; + let node: unknown = overrides; + let mergedNode: unknown = merged; + for (const part of issuePath) { + const key = String(part); + if (Array.isArray(node)) { + const arrayPath = segments.join('.'); + const keyField = hasOwn(ARRAY_MERGE_KEYS, arrayPath) + ? ARRAY_MERGE_KEYS[arrayPath] + : undefined; + const mergedItem = Array.isArray(mergedNode) ? mergedNode[Number(key)] : undefined; + const index = findItemIndex(node, key, keyField, mergedItem); + if (keyField && index < 0) { + return undefined; + } + if (!Number.isInteger(index) || index < 0 || index >= node.length) { + break; + } + segments.push(String(index)); + node = node[index]; + mergedNode = Array.isArray(mergedNode) + ? mergedNode[keyField ? Number(key) : index] + : undefined; + continue; + } + if (!isPlainObject(node) || !hasOwn(node, key)) { + break; + } + segments.push(key); + node = node[key]; + mergedNode = isPlainObject(mergedNode) ? mergedNode[key] : undefined; + } + return segments.length > 0 ? segments : undefined; +} + +/** + * Whether the override node at `segments` is final: at or inside an array the merge replaces + * (any array not merged by key), so no other layer can supply what it leaves out. + */ +function isReplacedArrayNode(overrides: AnyObject, segments: string[]): boolean { + let node: unknown = overrides; + for (let index = 0; index < segments.length; index++) { + node = Array.isArray(node) + ? node[Number(segments[index])] + : (node as AnyObject)[segments[index]]; + const path = segments.slice(0, index + 1).join('.'); + if (Array.isArray(node) && !hasOwn(ARRAY_MERGE_KEYS, path)) { + return true; + } + } + return false; +} + +/** + * Items of a merged-by-key array must be objects with their own key: the merge drops any + * other item (or keeps it as is when nothing lies beneath the array). A repeated key is + * reported on the earlier items: the last one is what the merge keeps. + */ +function getMergeKeyIssues(overrides: AnyObject): ConfigOverrideIssue[] { + return Object.entries(ARRAY_MERGE_KEYS).flatMap(([arrayPath, keyField]) => { + const segments = arrayPath.split('.'); + let node: unknown = overrides; + for (const segment of segments) { + node = isPlainObject(node) && hasOwn(node, segment) ? node[segment] : undefined; + } + if (!Array.isArray(node)) { + return []; + } + const lastIndex = new Map(); + node.forEach((item, index) => isPlainObject(item) && lastIndex.set(item[keyField], index)); + return node.flatMap((item, index) => { + if (!isPlainObject(item) || typeof item[keyField] !== 'string' || item[keyField] === '') { + return [toIssue([...segments, String(index)], 'missing_merge_key')]; + } + return lastIndex.get(item[keyField]) === index + ? [] + : [toIssue([...segments, String(index)], 'duplicate_merge_key')]; + }); + }); +} + +type SchemaIssue = NonNullable< + ReturnType['error'] +>['issues'][number]; + +/** + * A union reports one issue at its own path; the branch whose failures are fewest, then + * deepest, is the shape the value was written for, so its issues locate the actual fault. + */ +function expandUnionIssues(issues: SchemaIssue[], depth = 0): SchemaIssue[] { + return issues.flatMap((issue) => { + if (issue.code !== 'invalid_union' || depth >= MAX_MERGE_DEPTH) { + return [issue]; + } + const branches = issue.unionErrors.map((error) => error.issues); + const closest = branches.reduce((best, branch) => { + if (!best || branch.length < best.length) { + return branch; + } + const depthOf = (list: SchemaIssue[]) => Math.max(0, ...list.map((i) => i.path.length)); + return branch.length === best.length && depthOf(branch) > depthOf(best) ? branch : best; + }, undefined); + return closest && closest.length > 0 ? expandUnionIssues(closest, depth + 1) : [issue]; + }); +} + +/** The parts of a zod schema the stripped-key walk reads, without depending on zod. */ +type SchemaNode = { + _def: { + typeName?: string; + innerType?: SchemaNode; + schema?: SchemaNode; + type?: SchemaNode; + valueType?: SchemaNode; + options?: SchemaNode[] | Map; + }; + shape?: Record; + safeParse: (value: unknown) => { success: boolean }; +}; + +/** The schema beneath optional, nullable, default and refinement wrappers. */ +function unwrapSchema(schema: SchemaNode): SchemaNode { + let node = schema; + for (let depth = 0; depth < MAX_MERGE_DEPTH; depth++) { + const inner = node._def.innerType ?? node._def.schema; + if (!inner) { + break; + } + node = inner; + } + return node; +} + +/** + * Keys a union dropped: an object parses with the first union option that accepts it, and + * that option strips keys it does not define, so a key another option defines (and would + * type-check) is kept raw in the stored override but never validated. Keys no option defines + * are unknown keys and stay accepted. + */ +function findUnionDroppedKeys( + schema: SchemaNode, + value: unknown, + path: IssuePath, + depth = 0, +): IssuePath[] { + if (depth >= MAX_MERGE_DEPTH) { + return []; + } + const node = unwrapSchema(schema); + const { typeName } = node._def; + if (typeName === 'ZodObject' && node.shape && isPlainObject(value)) { + const shape = node.shape; + return Object.keys(value) + .filter((key) => hasOwn(shape, key)) + .flatMap((key) => findUnionDroppedKeys(shape[key], value[key], [...path, key], depth + 1)); + } + if (typeName === 'ZodArray' && node._def.type && Array.isArray(value)) { + const item = node._def.type; + return value.flatMap((entry, index) => + findUnionDroppedKeys(item, entry, [...path, index], depth + 1), + ); + } + if (typeName === 'ZodRecord' && node._def.valueType && isPlainObject(value)) { + const entrySchema = node._def.valueType; + return Object.entries(value).flatMap(([key, entry]) => + findUnionDroppedKeys(entrySchema, entry, [...path, key], depth + 1), + ); + } + if ( + (typeName !== 'ZodUnion' && typeName !== 'ZodDiscriminatedUnion') || + !node._def.options || + !isPlainObject(value) + ) { + return []; + } + const options = [...node._def.options.values()]; + const accepted = options.find((option) => option.safeParse(value).success); + if (!accepted) { + return []; + } + const acceptedShape = unwrapSchema(accepted).shape; + const defined = new Set( + options.flatMap((option) => Object.keys(unwrapSchema(option).shape ?? {})), + ); + const dropped = acceptedShape + ? Object.keys(value) + .filter((key) => !hasOwn(acceptedShape, key) && defined.has(key)) + .map((key) => [...path, key]) + : []; + return [...dropped, ...findUnionDroppedKeys(accepted, value, path, depth + 1)]; +} + +/** + * Checks config overrides the way they apply: merged over `base` (YAML-shaped), each + * section they touch parsed with its `configSchema` schema, and every failure attributed + * to the override node that caused it. Unions, refinements, required fields and defaults + * are therefore judged on the merged result, not on the patch alone. An issue caused only + * by leaving something out (a required or related field another layer may supply) is not + * reported, except inside an array the merge replaces, where nothing can supply it. Keys the schema does not define are accepted unchanged. + */ +export function getConfigOverrideIssues( + overrides: unknown, + base: Partial = {}, +): ConfigOverrideIssue[] { + if (!isPlainObject(overrides)) { + return [toIssue([], 'invalid_document')]; + } + const merged = deepMerge(base as AnyObject, overrides); + const issues = getMergeKeyIssues(overrides); + const seen = new Set(issues.map((issue) => issue.path)); + const shape = configSchema.shape; + for (const section of Object.keys(overrides)) { + if (!hasOwn(shape, section)) { + continue; + } + const schema = shape[section as keyof typeof shape] as unknown as SchemaNode; + const result = shape[section as keyof typeof shape].safeParse(merged[section]); + const dropped = findUnionDroppedKeys(schema, merged[section], []).map((path) => ({ + code: 'union_dropped_key', + path, + })); + const schemaIssues = result.success ? [] : expandUnionIssues(result.error.issues); + for (const issue of [...dropped, ...schemaIssues]) { + if (issue.code === 'unrecognized_keys') { + continue; + } + const issuePath: IssuePath = [section, ...issue.path]; + const segments = attributeIssue(overrides, merged, issuePath); + if ( + !segments || + (segments.length < issuePath.length && !isReplacedArrayNode(overrides, segments)) + ) { + continue; + } + const path = segments.join('.'); + if (seen.has(path)) { + continue; + } + seen.add(path); + issues.push(toIssue(segments, issue.code)); + } + } + return issues; +} + +/** Sets a dot-path the way a Mongo `$set` on `overrides.` does, without mutating. */ +function setPath(target: unknown, segments: string[], value: unknown): unknown { + if (segments.length === 0) { + return value; + } + const [segment, ...rest] = segments; + if (Array.isArray(target) && /^\d+$/.test(segment)) { + const next = [...target]; + next[Number(segment)] = setPath(next[Number(segment)], rest, value); + return next; + } + const next: AnyObject = isPlainObject(target) ? { ...target } : {}; + next[segment] = setPath(next[segment], rest, value); + return next; +} + +function isRelatedPath(a: string[], b: string[]): boolean { + const length = Math.min(a.length, b.length); + return a.slice(0, length).every((segment, index) => segment === b[index]); +} + +/** + * The base a principal's override lands on: `base` without the paths the principal + * tombstones, except those in `cleared` (tombstones the pending write removes). + */ +export function applyConfigTombstones( + base: Partial, + tombstones: unknown[] | undefined, + cleared: Set = new Set(), +): Partial { + return (tombstones ?? []) + .filter((path): path is string => typeof path === 'string' && !cleared.has(path)) + .reduce((current, path) => deletePath(current, path), base as AnyObject); +} + +/** + * Checks dot-path field writes on top of the principal's stored config, building the + * result the way `patchConfigFields` stores it: a Mongo `$set` on each `overrides.`, + * clearing the tombstones those paths clear. The principal's remaining tombstones are + * applied to the base. An issue is reported when it touches a written path, or when the + * write introduced it elsewhere (a related field the write made invalid); issues the stored + * config already had are left to merge time so they do not block an unrelated write. + */ +export function getConfigFieldIssues( + fields: Record, + base: Partial = {}, + stored?: { overrides?: unknown; tombstones?: unknown[] } | null, +): ConfigOverrideIssue[] { + const written = Object.keys(fields).map((fieldPath) => fieldPath.split('.')); + const cleared = new Set(Object.keys(fields).flatMap(getTombstonePathsToClear)); + const effectiveBase = applyConfigTombstones(base, stored?.tombstones, cleared); + const storedOverrides = isPlainObject(stored?.overrides) ? stored.overrides : {}; + const candidate = Object.entries(fields).reduce( + (current, [fieldPath, value]) => setPath(current, fieldPath.split('.'), value), + storedOverrides, + ); + /** + * An item of a merged-by-key array is identified by its key, not its position: an indexed + * write can rename the stored item, which the runtime merge then treats as a new one. + */ + const indexed = Object.keys(ARRAY_MERGE_KEYS).flatMap((arrayPath) => { + const arraySegments = arrayPath.split('.'); + return written.some( + (segments) => + segments.length > arraySegments.length && isRelatedPath(segments, arraySegments), + ) + ? [toIssue(arraySegments, 'indexed_merge_key_write')] + : []; + }); + if (indexed.length > 0) { + return indexed; + } + const existing = new Set( + getConfigOverrideIssues(storedOverrides, effectiveBase).map((issue) => issue.path), + ); + return getConfigOverrideIssues(candidate, effectiveBase).filter( + (issue) => + !existing.has(issue.path) || + written.some((segments) => isRelatedPath(issue.segments, segments)), + ); +} + +function omitPath(target: unknown, segments: string[]): unknown { + const [segment, ...rest] = segments; + if (Array.isArray(target)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= target.length) { + return target; + } + const next = [...target]; + if (rest.length === 0) { + next.splice(index, 1); + } else { + next[index] = omitPath(next[index], rest); + } + return next; + } + if (!isPlainObject(target) || !hasOwn(target, segment)) { + return target; + } + const next = { ...target }; + const child = rest.length === 0 ? undefined : omitPath(next[segment], rest); + /** A node its repairs emptied supplies nothing, so the value beneath it stays instead. */ + const emptied = child != null && typeof child === 'object' && Object.keys(child).length === 0; + if (rest.length === 0 || emptied) { + delete next[segment]; + } else { + next[segment] = child; + } + return next; +} + +/** + * Drops the override nodes that make the merged config fail `configSchema`, so an invalid + * stored value (written before write-time validation, by an older server, or one that only + * fails once layered over other overrides) leaves the value beneath it in place. A layer + * is not judged on what it leaves out, since a higher-priority layer may supply it. + */ +function stripInvalidOverrides(config: IConfig, base: Partial): AnyObject { + const principal = `${config.principalType}/${config.principalId}`; + let stripped: unknown = config.overrides; + /** + * Removing a field can make a related field it supplies fail, so check again while + * removals make progress. If they stop, only the sections that still fail are dropped. + */ + for (let pass = 0; pass < MAX_STRIP_PASSES; pass++) { + const issues = getConfigOverrideIssues(stripped, base); + if (issues.length === 0) { + return stripped as AnyObject; + } + if (issues.some((issue) => issue.segments.length === 0)) { + logger.warn(`[mergeConfigOverrides] Ignoring malformed overrides document for ${principal}`); + return {}; + } + const before = stripped; + for (let index = issues.length - 1; index >= 0; index--) { + const { path, segments, code } = issues[index]; + logger.warn( + `[mergeConfigOverrides] Ignoring invalid override "${path}" for ${principal} (${code})`, + ); + stripped = omitPath(stripped, segments); + } + if (stripped === before) { + break; + } + } + const failing = new Set( + getConfigOverrideIssues(stripped, base).map((issue) => issue.segments[0]), + ); + logger.warn( + `[mergeConfigOverrides] Ignoring still-invalid sections ${[...failing].join(', ')} for ${principal}`, + ); + return Object.fromEntries( + Object.entries(stripped as AnyObject).filter(([section]) => !failing.has(section)), + ); +} + function filterMCPServerOverrides(value: unknown, current: unknown): AnyObject { if (value == null || typeof value !== 'object' || Array.isArray(value)) { return {}; @@ -289,6 +763,8 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): const sorted = [...configs].sort((a, b) => a.priority - b.priority); let merged = { ...baseConfig }; + /** The YAML-shaped config the next override lands on, for validating it in place. */ + let raw: Partial = baseConfig.config ?? {}; for (const config of sorted) { const isBasePrincipal = config.principalId?.toString() === BASE_CONFIG_PRINCIPAL_ID; if (Array.isArray(config.tombstones)) { @@ -299,25 +775,30 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): (isBasePrincipal || !BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(path.split('.')[0])) ) { merged = deleteConfigPath(merged, remapOverridePath(path)); + raw = deletePath(raw as AnyObject, path) as Partial; } } } if (config.overrides && typeof config.overrides === 'object') { const remapped: AnyObject = {}; - for (const [key, value] of Object.entries(config.overrides)) { + const applied: AnyObject = {}; + for (const [key, value] of Object.entries(stripInvalidOverrides(config, raw))) { if ( BASE_ONLY_OVERRIDE_SECTIONS.has(key) || (!isBasePrincipal && BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(key)) ) { continue; } + applied[key] = value; const mappedKey = OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key; if (mappedKey === 'mcpConfig') { remapped[mappedKey] = filterMCPServerOverrides( value, (merged as unknown as AnyObject)[mappedKey], ); + /** A server the filter removed must not complete a later layer's partial of it. */ + applied[key] = remapped[mappedKey]; } else if ( key === 'interface' && value != null && @@ -359,6 +840,7 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]): } } merged = deepMerge(merged, remapped); + raw = deepMerge(raw as AnyObject, applied) as Partial; } } diff --git a/packages/data-schemas/src/methods/config.ts b/packages/data-schemas/src/methods/config.ts index 16f94efd9ea..d3051506ab7 100644 --- a/packages/data-schemas/src/methods/config.ts +++ b/packages/data-schemas/src/methods/config.ts @@ -6,7 +6,8 @@ import type { IConfig } from '~/types'; import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities'; import { escapeRegExp } from '~/utils/string'; -function getTombstonePathsToClear(fieldPath: string): string[] { +/** Tombstones a field write clears: the written path and its ancestors below the section. */ +export function getTombstonePathsToClear(fieldPath: string): string[] { const parts = fieldPath.split('.'); if (parts.length <= 1) { return [fieldPath];