diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index b44ee085ba..d802c31f46 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -1,5 +1,19 @@ import type { Response } from 'express'; import type { ServerRequest } from '~/types/http'; + +process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +jest.mock('@librechat/data-schemas', () => { + process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + encryptV3: jest.fn((value: string) => `v3:test:${value}`), + }; +}); + import { createAdminConfigHandlers } from './config'; function mockReq(overrides = {}) { @@ -66,6 +80,40 @@ function createHandlers(overrides = {}) { } describe('createAdminConfigHandlers', () => { + describe('listConfigs', () => { + it('redacts secret fields from config list responses', async () => { + const { handlers } = createHandlers({ + listAllConfigs: jest.fn().mockResolvedValue([ + { + _id: 'c1', + principalType: 'role', + principalId: 'admin', + overrides: { + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'v3:encrypted', + displaySecretKey: 'sk-lf-...cret', + }, + }, + }, + ]), + }); + const req = mockReq(); + const res = mockRes(); + + await handlers.listConfigs(req, res); + + expect(res.statusCode).toBe(200); + const configs = res.body!.configs as Array<{ + overrides: { langfuse: Record }; + }>; + expect(configs[0].overrides.langfuse).toEqual({ + publicKey: 'pk-lf-1', + displaySecretKey: 'sk-lf-...cret', + }); + }); + }); + describe('getConfig', () => { it('returns 403 before DB lookup when user lacks READ_CONFIGS', async () => { const { handlers, deps } = createHandlers({ @@ -218,6 +266,195 @@ describe('createAdminConfigHandlers', () => { expect(savedOverrides.interface).toEqual({ modelSelect: false }); }); + it('encrypts Langfuse secret keys on full override writes', async () => { + process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const { handlers, deps } = createHandlers({ + upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({ + _id: 'c1', + configVersion: 1, + overrides, + })), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'sk-lf-secret', + }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + expect(savedOverrides.langfuse.secretKey).toMatch(/^v3:/); + expect(savedOverrides.langfuse.secretKey).not.toBe('sk-lf-secret'); + expect(savedOverrides.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + const responseConfig = res.body!.config as { + overrides: { langfuse: Record }; + }; + expect(responseConfig.overrides.langfuse).toEqual({ + publicKey: 'pk-lf-1', + displaySecretKey: savedOverrides.langfuse.displaySecretKey, + }); + }); + + it('preserves existing encrypted Langfuse secrets on full override writes when omitted', async () => { + const existing = { + _id: 'c1', + priority: 7, + overrides: { + langfuse: { + publicKey: 'pk-old', + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }, + }, + }; + const { handlers, deps } = createHandlers({ + findConfigByPrincipal: jest.fn().mockResolvedValue(existing), + upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({ + _id: 'c1', + configVersion: 2, + overrides, + })), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + langfuse: { + publicKey: 'pk-new', + destination: 'eu', + }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + expect(savedOverrides.langfuse).toEqual({ + publicKey: 'pk-new', + destination: 'eu', + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }); + const responseConfig = res.body!.config as { + overrides: { langfuse: Record }; + }; + expect(responseConfig.overrides.langfuse).toEqual({ + publicKey: 'pk-new', + destination: 'eu', + displaySecretKey: 'sk-old...-old', + }); + }); + + it('clears existing Langfuse secrets on full override writes when explicitly empty', async () => { + const { handlers, deps } = createHandlers({ + findConfigByPrincipal: jest.fn().mockResolvedValue({ + _id: 'c1', + overrides: { + langfuse: { + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }, + }, + }), + upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({ + _id: 'c1', + configVersion: 2, + overrides, + })), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + langfuse: { + publicKey: 'pk-new', + secretKey: '', + }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(200); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + expect(savedOverrides.langfuse).toEqual({ + publicKey: 'pk-new', + secretKey: '', + displaySecretKey: '', + }); + }); + + it('rejects encrypted Langfuse secret values on full override writes', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'v3:attacker-controlled', + }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it('does not persist literal dotted Langfuse secret keys on full override writes', async () => { + const { handlers, deps } = createHandlers({ + upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({ + _id: 'c1', + configVersion: 1, + overrides, + })), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + overrides: { + 'langfuse.secretKey': 'sk-lf-secret', + 'langfuse.displaySecretKey': 'spoofed', + langfuse: { publicKey: 'pk-lf-1' }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(201); + const savedOverrides = deps.upsertConfig.mock.calls[0][3]; + expect(savedOverrides).not.toHaveProperty('langfuse.secretKey'); + expect(savedOverrides).not.toHaveProperty('langfuse.displaySecretKey'); + expect(savedOverrides.langfuse).toEqual({ publicKey: 'pk-lf-1' }); + const responseConfig = res.body!.config as { + overrides: { langfuse: Record }; + }; + expect(responseConfig.overrides).toEqual({ + langfuse: { publicKey: 'pk-lf-1' }, + }); + }); + it('preserves UI sub-keys in composite permission fields like mcpServers', async () => { const { handlers, deps } = createHandlers({ upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }), @@ -394,6 +631,39 @@ describe('createAdminConfigHandlers', () => { expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect'); }); + it('also deletes the display secret key companion when deleting a secret field', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'langfuse.secretKey' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'langfuse.secretKey'); + expect(deps.unsetConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + 'langfuse.displaySecretKey', + ); + }); + + it('rejects deletes of the displayed secret key', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + query: { fieldPath: 'langfuse.displaySecretKey' }, + }); + const res = mockRes(); + + await handlers.deleteConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); + }); + it('returns 400 when fieldPath query param is missing', async () => { const { handlers } = createHandlers(); const req = mockReq({ @@ -464,6 +734,47 @@ describe('createAdminConfigHandlers', () => { ); }); + it('also tombstones the display secret key companion when tombstoning a secret field', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { fieldPath: 'langfuse.secretKey' }, + }); + const res = mockRes(); + + await handlers.tombstoneConfigField(req, res); + + expect(res.statusCode).toBe(200); + expect(deps.tombstoneConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + expect.anything(), + 'langfuse.secretKey', + 10, + ); + expect(deps.tombstoneConfigField).toHaveBeenCalledWith( + 'role', + 'admin', + expect.anything(), + 'langfuse.displaySecretKey', + 10, + ); + }); + + it('rejects tombstones of the displayed secret key', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { fieldPath: 'langfuse.displaySecretKey' }, + }); + const res = mockRes(); + + await handlers.tombstoneConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.tombstoneConfigField).not.toHaveBeenCalled(); + }); + it('blocks interface permission paths', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ @@ -552,6 +863,238 @@ describe('createAdminConfigHandlers', () => { expect(patchedFields['interface.modelSelect']).toBe(false); }); + it('clears stale Langfuse display secret keys when clearing a secret', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.secretKey', value: '' }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields['langfuse.secretKey']).toBe(''); + expect(patchedFields['langfuse.displaySecretKey']).toBe(''); + }); + + it('encrypts Langfuse secret keys inside object-valued patch entries', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { + fieldPath: 'langfuse', + value: { + publicKey: 'pk-lf-1', + secretKey: 'sk-lf-secret', + }, + }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields.langfuse.secretKey).toMatch(/^v3:/); + expect(patchedFields.langfuse.secretKey).not.toBe('sk-lf-secret'); + expect(patchedFields.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + }); + + it('preserves existing encrypted Langfuse secrets on object-valued patch entries when omitted', async () => { + const { handlers, deps } = createHandlers({ + findConfigByPrincipal: jest.fn().mockResolvedValue({ + _id: 'c1', + priority: 7, + overrides: { + langfuse: { + publicKey: 'pk-old', + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }, + }, + }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + priority: 12, + entries: [ + { + fieldPath: 'langfuse', + value: { + publicKey: 'pk-new', + destination: 'eu', + }, + }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields.langfuse).toEqual({ + publicKey: 'pk-new', + destination: 'eu', + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }); + expect(deps.findConfigByPrincipal).toHaveBeenCalled(); + }); + + it('clears existing Langfuse secrets on object-valued patch entries when explicitly empty', async () => { + const { handlers, deps } = createHandlers({ + findConfigByPrincipal: jest.fn().mockResolvedValue({ + _id: 'c1', + priority: 7, + overrides: { + langfuse: { + secretKey: 'v3:test:sk-old', + displaySecretKey: 'sk-old...-old', + }, + }, + }), + }); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { + fieldPath: 'langfuse', + value: { + publicKey: 'pk-new', + secretKey: '', + }, + }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields.langfuse).toEqual({ + publicKey: 'pk-new', + secretKey: '', + displaySecretKey: '', + }); + }); + + it('rejects array-valued Langfuse secret ancestors', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [ + { + fieldPath: 'langfuse', + value: [{ secretKey: 'sk-lf-secret' }], + }, + ], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('does not store non-string values at Langfuse secret paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.secretKey', value: { hidden: 'sk-lf-secret' } }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(200); + const patchedFields = deps.patchConfigFields.mock.calls[0][3]; + expect(patchedFields['langfuse.secretKey']).toBe(''); + expect(patchedFields['langfuse.displaySecretKey']).toBe(''); + }); + + it('rejects direct display secret key patch entries', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.displaySecretKey', value: 'spoofed' }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('rejects encrypted Langfuse secret values on patch entries', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.secretKey', value: 'v3:attacker-controlled' }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('rejects patch entries below protected Langfuse secret paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.secretKey.hidden', value: 'sk-lf-secret' }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + + it('rejects patch entries below protected Langfuse displaySecretKey paths', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'role', principalId: 'admin' }, + body: { + entries: [{ fieldPath: 'langfuse.displaySecretKey.hidden', value: 'spoofed' }], + }, + }); + const res = mockRes(); + + await handlers.patchConfigField(req, res); + + expect(res.statusCode).toBe(400); + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + it('blocks peoplePicker permission sub-key paths', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ @@ -1552,6 +2095,43 @@ describe('createAdminConfigHandlers', () => { expect(res.body!.config).toEqual({ interface: { modelSelect: true } }); }); + it('redacts Langfuse secrets from top-level and raw nested base config', async () => { + const { handlers } = createHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'sk-lf-secret', + displaySecretKey: 'sk-lf-...cret', + }, + config: { + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'sk-lf-raw-secret', + displaySecretKey: 'sk-lf-...cret', + }, + }, + }), + }); + const req = mockReq(); + const res = mockRes(); + + await handlers.getBaseConfig(req, res); + + expect(res.statusCode).toBe(200); + const responseConfig = res.body!.config as { + langfuse: Record; + config: { langfuse: Record }; + }; + expect(responseConfig.langfuse).toEqual({ + publicKey: 'pk-lf-1', + displaySecretKey: 'sk-lf-...cret', + }); + expect(responseConfig.config.langfuse).toEqual({ + publicKey: 'pk-lf-1', + displaySecretKey: 'sk-lf-...cret', + }); + }); + it('forwards baseOnly=true to getAppConfig when query param is the literal string "true"', async () => { const getAppConfig = jest.fn().mockResolvedValue({ interface: { modelSelect: true } }); const { handlers } = createHandlers({ getAppConfig }); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 30d3a4d79c..f1e3558f0d 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -12,6 +12,16 @@ import type { Types, ClientSession } from 'mongoose'; import type { Response } from 'express'; import type { CapabilityUser } from '~/middleware/capabilities'; import type { ServerRequest } from '~/types/http'; +import { + encryptConfigSecretFields, + encryptConfigSecrets, + getConfigSecretMutationPaths, + getConfigSecretInputError, + isConfigSecretAncestorPath, + isConfigSecretDescendantPath, + preserveConfigSecrets, + redactConfigSecrets, +} from './secrets'; const UNSAFE_SEGMENTS = /(?:^|\.)(__[\w]*|constructor|prototype)(?:\.|$)/; const MAX_PATCH_ENTRIES = 100; @@ -173,6 +183,45 @@ function getCapabilityUser(req: ServerRequest): CapabilityUser | null { }; } +function redactConfigForResponse(config: IConfig): IConfig { + const safeConfig = JSON.parse(JSON.stringify(config)) as IConfig; + if (safeConfig.overrides) { + redactConfigSecrets(safeConfig.overrides); + } + return safeConfig; +} + +function redactAppConfigForResponse(appConfig: AppConfig): AppConfig { + const safeConfig = JSON.parse(JSON.stringify(appConfig)) as AppConfig & { config?: unknown }; + redactConfigSecrets(safeConfig); + if (safeConfig.config != null && typeof safeConfig.config === 'object') { + redactConfigSecrets(safeConfig.config); + } + return safeConfig; +} + +function isObjectValuedLangfusePatch(fieldPath: string, value: unknown): boolean { + return ( + isConfigSecretAncestorPath(fieldPath) && + value != null && + typeof value === 'object' && + !Array.isArray(value) + ); +} + +function preservePatchedConfigSecretFields( + fields: Record, + existingOverrides?: unknown, +): Record { + const result = { ...fields }; + for (const [fieldPath, value] of Object.entries(result)) { + if (isObjectValuedLangfusePatch(fieldPath, value)) { + result[fieldPath] = preserveConfigSecrets(value, existingOverrides, fieldPath); + } + } + return result; +} + // ── Handler factory ────────────────────────────────────────────────── export function createAdminConfigHandlers(deps: AdminConfigDeps): { @@ -216,7 +265,8 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { } const configs = await listAllConfigs(); - return res.status(200).json({ configs }); + const safeConfigs = configs.map(redactConfigForResponse); + return res.status(200).json({ configs: safeConfigs }); } catch (error) { logger.error('[adminConfig] listConfigs error:', error); return res.status(500).json({ error: 'Failed to list configs' }); @@ -247,7 +297,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { tenantId: user.tenantId, baseOnly, }); - return res.status(200).json({ config: appConfig }); + return res.status(200).json({ config: redactAppConfigForResponse(appConfig) }); } catch (error) { logger.error('[adminConfig] getBaseConfig error:', error); return res.status(500).json({ error: 'Failed to get base config' }); @@ -284,7 +334,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(404).json({ error: 'Config not found' }); } - return res.status(200).json({ config }); + return res.status(200).json({ config: redactConfigForResponse(config) }); } catch (error) { logger.error('[adminConfig] getConfig error:', error); return res.status(500).json({ error: 'Failed to get config' }); @@ -404,11 +454,30 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { ? { expectEmpty: false } : { expectEmpty: true, preservePriority: true }; + const langfuseInputError = getConfigSecretInputError( + 'langfuse', + (filteredOverrides as Record).langfuse, + ); + if (langfuseInputError) { + return res.status(400).json({ error: langfuseInputError }); + } + + const encryptedOverrides = encryptConfigSecrets(filteredOverrides); + const existingForSecrets = isObjectValuedLangfusePatch( + 'langfuse', + (filteredOverrides as Record).langfuse, + ) + ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) + : null; + const preservedOverrides = preserveConfigSecrets( + encryptedOverrides, + existingForSecrets?.overrides, + ); const config = await upsertConfig( principalType, principalId, principalModel(principalType), - filteredOverrides, + preservedOverrides, requestedPriority, undefined, upsertOptions, @@ -420,7 +489,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { invalidateConfigCaches?.(user.tenantId)?.catch((err) => logger.error('[adminConfig] Cache invalidation failed after upsert:', err), ); - return res.status(config?.configVersion === 1 ? 201 : 200).json({ config }); + return res.status(config?.configVersion === 1 ? 201 : 200).json({ + config: config ? redactConfigForResponse(config) : config, + }); } catch (error) { logger.error('[adminConfig] upsertConfigOverrides error:', error); return res.status(500).json({ error: 'Failed to upsert config' }); @@ -466,6 +537,20 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { .status(400) .json({ error: `Invalid or unsafe field path: ${entry.fieldPath}` }); } + if (isConfigSecretDescendantPath(entry.fieldPath)) { + return res + .status(400) + .json({ error: `Cannot patch inside protected secret path: ${entry.fieldPath}` }); + } + const secretInputError = getConfigSecretInputError(entry.fieldPath, entry.value); + if (secretInputError) { + return res.status(400).json({ error: secretInputError }); + } + if (Array.isArray(entry.value) && isConfigSecretAncestorPath(entry.fieldPath)) { + return res.status(400).json({ + error: `Cannot patch protected secret ancestor as an array: ${entry.fieldPath}`, + }); + } } const user = getCapabilityUser(req); @@ -528,23 +613,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { } const requestedPriority = hasBroadManage ? priority : undefined; + const hasObjectValuedLangfusePatch = Object.entries(fields).some(([fieldPath, value]) => + isObjectValuedLangfusePatch(fieldPath, value), + ); const existing = - requestedPriority == null + requestedPriority == null || hasObjectValuedLangfusePatch ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; + const encryptedFields = encryptConfigSecretFields(fields); + const preservedFields = preservePatchedConfigSecretFields( + encryptedFields, + existing?.overrides, + ); const config = await patchConfigFields( principalType, principalId, principalModel(principalType), - fields, + preservedFields, 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 }); + return res.status(200).json({ config: config ? redactConfigForResponse(config) : config }); } catch (error) { logger.error('[adminConfig] patchConfigField error:', error); return res.status(500).json({ error: 'Failed to patch config fields' }); @@ -581,6 +674,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { if (!isValidFieldPath(fieldPath)) { return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` }); } + const secretInputError = getConfigSecretInputError(fieldPath, undefined); + if (secretInputError) { + return res.status(400).json({ error: secretInputError }); + } const user = getCapabilityUser(req); if (!user) { @@ -618,18 +715,24 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; - const config = await writeConfigTombstone( - principalType, - principalId, - principalModel(principalType), - fieldPath, - requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY, - ); + let config: IConfig | null = null; + for (const path of getConfigSecretMutationPaths(fieldPath)) { + const fieldConfig = await writeConfigTombstone( + principalType, + principalId, + principalModel(principalType), + path, + requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY, + ); + if (fieldConfig) { + config = fieldConfig; + } + } invalidateConfigCaches?.(user.tenantId)?.catch((err) => logger.error('[adminConfig] Cache invalidation failed after field tombstone:', err), ); - return res.status(200).json({ config }); + return res.status(200).json({ config: config ? redactConfigForResponse(config) : config }); } catch (error) { logger.error('[adminConfig] tombstoneConfigField error:', error); return res.status(500).json({ error: 'Failed to tombstone config field' }); @@ -658,6 +761,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { if (!isValidFieldPath(fieldPath)) { return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` }); } + const secretInputError = getConfigSecretInputError(fieldPath, undefined); + if (secretInputError) { + return res.status(400).json({ error: secretInputError }); + } const user = getCapabilityUser(req); if (!user) { @@ -686,7 +793,13 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(200).json({ message: 'No actionable field path provided' }); } - const config = await unsetConfigField(principalType, principalId, fieldPath); + let config: IConfig | null = null; + for (const path of getConfigSecretMutationPaths(fieldPath)) { + const fieldConfig = await unsetConfigField(principalType, principalId, path); + if (fieldConfig) { + config = fieldConfig; + } + } if (!config) { return res.status(404).json({ error: 'Config not found' }); } @@ -694,7 +807,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { invalidateConfigCaches?.(user.tenantId)?.catch((err) => logger.error('[adminConfig] Cache invalidation failed after field delete:', err), ); - return res.status(200).json({ config }); + return res.status(200).json({ config: redactConfigForResponse(config) }); } catch (error) { logger.error('[adminConfig] deleteConfigField error:', error); return res.status(500).json({ error: 'Failed to delete config field' }); @@ -813,7 +926,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { invalidateConfigCaches?.(user.tenantId)?.catch((err) => logger.error('[adminConfig] Cache invalidation failed after toggle:', err), ); - return res.status(200).json({ config }); + return res.status(200).json({ config: redactConfigForResponse(config) }); } catch (error) { logger.error('[adminConfig] toggleConfig error:', error); return res.status(500).json({ error: 'Failed to toggle config' }); diff --git a/packages/api/src/admin/secrets.spec.ts b/packages/api/src/admin/secrets.spec.ts new file mode 100644 index 0000000000..0296f38385 --- /dev/null +++ b/packages/api/src/admin/secrets.spec.ts @@ -0,0 +1,193 @@ +process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +// Loaded via dynamic import in beforeAll so encryption initializes after +// CREDS_KEY is set above (encryptV3 reads the key at module load). +let decryptConfigSecret: typeof import('./secrets').decryptConfigSecret; +let encryptConfigSecretFields: typeof import('./secrets').encryptConfigSecretFields; +let encryptConfigSecrets: typeof import('./secrets').encryptConfigSecrets; +let getConfigSecretInputError: typeof import('./secrets').getConfigSecretInputError; +let preserveConfigSecrets: typeof import('./secrets').preserveConfigSecrets; +let redactConfigSecrets: typeof import('./secrets').redactConfigSecrets; +let decryptV3: typeof import('@librechat/data-schemas').decryptV3; + +beforeAll(async () => { + ({ + decryptConfigSecret, + encryptConfigSecretFields, + encryptConfigSecrets, + getConfigSecretInputError, + preserveConfigSecrets, + redactConfigSecrets, + } = await import('./secrets')); + ({ decryptV3 } = await import('@librechat/data-schemas')); +}); + +describe('Langfuse config secrets', () => { + it('encrypts direct field writes and stores a display secret key', () => { + const out = encryptConfigSecretFields({ + 'langfuse.publicKey': 'pk-lf-1', + 'langfuse.secretKey': 'sk-lf-secret', + }); + + expect(out['langfuse.secretKey']).toMatch(/^v3:/); + expect(decryptV3(out['langfuse.secretKey'] as string)).toBe('sk-lf-secret'); + expect(out['langfuse.displaySecretKey']).toBe('sk-lf-...cret'); + expect(out['langfuse.publicKey']).toBe('pk-lf-1'); + }); + + it('encrypts object writes and removes client-supplied display secret keys', () => { + const out = encryptConfigSecrets({ + langfuse: { + publicKey: 'pk-lf-1', + secretKey: 'sk-lf-secret', + displaySecretKey: 'spoofed', + }, + }); + + expect(out.langfuse.secretKey).toMatch(/^v3:/); + expect(decryptV3(out.langfuse.secretKey)).toBe('sk-lf-secret'); + expect(out.langfuse.displaySecretKey).toBe('sk-lf-...cret'); + expect(out.langfuse.publicKey).toBe('pk-lf-1'); + }); + + it('clears empty or non-string secret values', () => { + expect(encryptConfigSecretFields({ 'langfuse.secretKey': '' })).toEqual({ + 'langfuse.secretKey': '', + 'langfuse.displaySecretKey': '', + }); + + expect( + encryptConfigSecrets({ + langfuse: { + secretKey: null, + displaySecretKey: 'spoofed', + }, + }), + ).toEqual({ + langfuse: { + secretKey: '', + displaySecretKey: '', + }, + }); + }); + + it('rejects protected display-key writes and encrypted secret submissions', () => { + expect(getConfigSecretInputError('langfuse.displaySecretKey', 'spoofed')).toContain( + 'protected display secret path', + ); + expect(getConfigSecretInputError('langfuse.secretKey', 'v3:attacker-controlled')).toContain( + 'Encrypted config secret values', + ); + expect( + getConfigSecretInputError('langfuse', { secretKey: 'v3:attacker-controlled' }), + ).toContain('Encrypted config secret values'); + expect(getConfigSecretInputError('langfuse.secretKey', 'sk-lf-secret')).toBeNull(); + }); + + it('decrypts encrypted config secrets and rejects plaintext runtime values', () => { + const encrypted = encryptConfigSecrets({ + langfuse: { secretKey: 'sk-lf-secret' }, + }).langfuse.secretKey; + + expect(decryptConfigSecret(encrypted)).toBe('sk-lf-secret'); + expect(decryptConfigSecret(' sk-plaintext ')).toBeUndefined(); + expect(decryptConfigSecret('')).toBeUndefined(); + expect(decryptConfigSecret('v3:not-valid-ciphertext')).toBeUndefined(); + }); + + it('preserves existing encrypted secrets when object writes omit them', () => { + const existing = encryptConfigSecrets({ + langfuse: { + publicKey: 'pk-old', + secretKey: 'sk-old', + }, + }); + const next = encryptConfigSecrets({ + langfuse: { + publicKey: 'pk-new', + }, + }); + + const preserved = preserveConfigSecrets(next, existing); + const preservedLangfuse = preserved.langfuse as Record; + const existingLangfuse = existing.langfuse as Record; + + expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old'); + expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey); + expect(preserved.langfuse.publicKey).toBe('pk-new'); + }); + + it('does not preserve plaintext existing secrets or explicitly cleared secrets', () => { + const next = encryptConfigSecrets({ + langfuse: { + publicKey: 'pk-new', + }, + }); + + const fromPlaintext = preserveConfigSecrets(next, { + langfuse: { + publicKey: 'pk-old', + secretKey: 'sk-plain-existing', + }, + }); + expect(fromPlaintext.langfuse).toEqual({ publicKey: 'pk-new' }); + + const existing = encryptConfigSecrets({ + langfuse: { + secretKey: 'sk-old', + }, + }); + const cleared = encryptConfigSecrets({ + langfuse: { + secretKey: '', + }, + }); + expect(preserveConfigSecrets(cleared, existing)).toEqual({ + langfuse: { + secretKey: '', + displaySecretKey: '', + }, + }); + }); + + it('preserves existing secrets for object-valued ancestor patches', () => { + const existing = encryptConfigSecrets({ + langfuse: { + publicKey: 'pk-old', + secretKey: 'sk-old', + }, + }); + + const preserved = preserveConfigSecrets({ publicKey: 'pk-new' }, existing, 'langfuse'); + const preservedLangfuse = preserved as Record; + const existingLangfuse = existing.langfuse as Record; + + expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old'); + expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey); + expect(preserved.publicKey).toBe('pk-new'); + }); + + it('redacts secret values while preserving display secret keys', () => { + const redacted = redactConfigSecrets({ + 'langfuse.secretKey': 'literal', + 'langfuse.displaySecretKey': 'literal-display', + langfuse: { + enabled: true, + destination: 'eu', + publicKey: 'pk-lf-1', + secretKey: 'v3:abc:def', + displaySecretKey: 'sk-lf-...cret', + }, + }); + + expect(redacted['langfuse.secretKey']).toBeUndefined(); + expect(redacted['langfuse.displaySecretKey']).toBeUndefined(); + expect(redacted.langfuse).toEqual({ + enabled: true, + destination: 'eu', + publicKey: 'pk-lf-1', + displaySecretKey: 'sk-lf-...cret', + }); + }); +}); diff --git a/packages/api/src/admin/secrets.ts b/packages/api/src/admin/secrets.ts new file mode 100644 index 0000000000..49a4027962 --- /dev/null +++ b/packages/api/src/admin/secrets.ts @@ -0,0 +1,226 @@ +import isPlainObject from 'lodash/isPlainObject'; +import { encryptV3, decryptV3, logger } from '@librechat/data-schemas'; + +const LANGFUSE_SECTION = 'langfuse'; +const LANGFUSE_SECRET_KEY = 'secretKey'; +const LANGFUSE_DISPLAY_SECRET_KEY = 'displaySecretKey'; +const LANGFUSE_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_SECRET_KEY}`; +const LANGFUSE_DISPLAY_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_DISPLAY_SECRET_KEY}`; +const ENCRYPTED_PREFIX = 'v3:'; + +export function getDisplaySecretKey(secret: string): string { + return secret.slice(0, 6) + '...' + secret.slice(-4); +} + +function normalizeSecretString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function isEncryptedConfigSecret(value: unknown): boolean { + return typeof value === 'string' && value.trim().startsWith(ENCRYPTED_PREFIX); +} + +function getPlainRecord(value: unknown): Record | null { + return isPlainObject(value) ? (value as Record) : null; +} + +function getLangfuseSection(root: unknown, basePath = ''): Record | null { + const rootRecord = getPlainRecord(root); + if (!rootRecord) { + return null; + } + if (basePath === LANGFUSE_SECTION) { + return rootRecord; + } + return getPlainRecord(rootRecord[LANGFUSE_SECTION]); +} + +export function decryptConfigSecret(value: unknown): string | undefined { + const normalized = normalizeSecretString(value); + if (!normalized || !normalized.startsWith(ENCRYPTED_PREFIX)) { + return undefined; + } + try { + return decryptV3(normalized); + } catch (error) { + logger.warn('[adminConfig] Failed to decrypt config secret', error); + return undefined; + } +} + +export function getConfigSecretMutationPaths(fieldPath: string): string[] { + if (fieldPath === LANGFUSE_SECRET_PATH) { + return [LANGFUSE_SECRET_PATH, LANGFUSE_DISPLAY_SECRET_PATH]; + } + return [fieldPath]; +} + +export function isConfigSecretDescendantPath(fieldPath: string): boolean { + return ( + fieldPath.startsWith(`${LANGFUSE_SECRET_PATH}.`) || + fieldPath.startsWith(`${LANGFUSE_DISPLAY_SECRET_PATH}.`) + ); +} + +export function isConfigSecretAncestorPath(fieldPath: string): boolean { + return fieldPath === LANGFUSE_SECTION; +} + +export function getConfigSecretInputError(fieldPath: string, value: unknown): string | null { + if (fieldPath === LANGFUSE_DISPLAY_SECRET_PATH) { + return `Cannot write protected display secret path: ${fieldPath}`; + } + if (fieldPath === LANGFUSE_SECRET_PATH && isEncryptedConfigSecret(value)) { + return `Encrypted config secret values cannot be submitted: ${fieldPath}`; + } + const langfuseInput = fieldPath === LANGFUSE_SECTION ? getPlainRecord(value) : null; + if (langfuseInput && isEncryptedConfigSecret(langfuseInput[LANGFUSE_SECRET_KEY])) { + return `Encrypted config secret values cannot be submitted: ${LANGFUSE_SECRET_PATH}`; + } + return null; +} + +function removeLangfuseArraySection(root: Record): boolean { + if (Array.isArray(root[LANGFUSE_SECTION])) { + delete root[LANGFUSE_SECTION]; + return true; + } + return false; +} + +function applyLangfuseSecretWrite(section: Record): void { + if (!(LANGFUSE_SECRET_KEY in section)) { + delete section[LANGFUSE_DISPLAY_SECRET_KEY]; + return; + } + + const value = section[LANGFUSE_SECRET_KEY]; + if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) { + section[LANGFUSE_SECRET_KEY] = ''; + section[LANGFUSE_DISPLAY_SECRET_KEY] = ''; + return; + } + + section[LANGFUSE_SECRET_KEY] = encryptV3(value); + section[LANGFUSE_DISPLAY_SECRET_KEY] = getDisplaySecretKey(value); +} + +/** + * Returns a new field map with Langfuse secret entries encrypted and their + * displaySecretKey companion set. Empty values reset the secret and displaySecretKey. + */ +export function encryptConfigSecretFields( + fields: Record, +): Record { + const result: Record = { ...fields }; + + if (Array.isArray(result[LANGFUSE_SECTION])) { + delete result[LANGFUSE_SECTION]; + } else { + const section = getPlainRecord(result[LANGFUSE_SECTION]); + if (section) { + result[LANGFUSE_SECTION] = encryptConfigSecrets(section, LANGFUSE_SECTION); + } + } + + if (!(LANGFUSE_SECRET_PATH in result) && LANGFUSE_DISPLAY_SECRET_PATH in result) { + delete result[LANGFUSE_DISPLAY_SECRET_PATH]; + } + + if (LANGFUSE_SECRET_PATH in result) { + const value = result[LANGFUSE_SECRET_PATH]; + if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) { + result[LANGFUSE_SECRET_PATH] = ''; + result[LANGFUSE_DISPLAY_SECRET_PATH] = ''; + } else { + result[LANGFUSE_SECRET_PATH] = encryptV3(value); + result[LANGFUSE_DISPLAY_SECRET_PATH] = getDisplaySecretKey(value); + } + } + + return result; +} + +/** + * Returns a cloned config override object with Langfuse secret values encrypted + * before full-document writes. Empty secrets reset their displaySecretKey. + */ +export function encryptConfigSecrets(root: T, basePath = ''): T { + if (root == null || typeof root !== 'object') { + return root; + } + + const result = structuredClone(root); + if (basePath === '') { + delete (result as Record)[LANGFUSE_SECRET_PATH]; + delete (result as Record)[LANGFUSE_DISPLAY_SECRET_PATH]; + removeLangfuseArraySection(result as Record); + } + + const section = getLangfuseSection(result, basePath); + if (section) { + applyLangfuseSecretWrite(section); + } + return result; +} + +/** + * Preserves an existing encrypted Langfuse secret when a whole Langfuse object is + * replaced without a secret value. This lets redacted admin reads round-trip + * safely: omitting a secret keeps it, while setting it to an empty value clears it. + */ +export function preserveConfigSecrets(next: T, existing?: unknown, basePath = ''): T { + if ( + next == null || + typeof next !== 'object' || + existing == null || + typeof existing !== 'object' + ) { + return next; + } + + const result = structuredClone(next); + const section = getLangfuseSection(result, basePath); + const existingSection = getLangfuseSection(existing); + if ( + !section || + !existingSection || + LANGFUSE_SECRET_KEY in section || + !isEncryptedConfigSecret(existingSection[LANGFUSE_SECRET_KEY]) + ) { + return result; + } + + const existingSecret = normalizeSecretString(existingSection[LANGFUSE_SECRET_KEY]); + if (!existingSecret) { + return result; + } + section[LANGFUSE_SECRET_KEY] = existingSecret; + if (typeof existingSection[LANGFUSE_DISPLAY_SECRET_KEY] === 'string') { + section[LANGFUSE_DISPLAY_SECRET_KEY] = existingSection[LANGFUSE_DISPLAY_SECRET_KEY]; + } + return result; +} + +/** + * Deletes Langfuse secret fields from `root` in place so admin reads never + * return secret values (encrypted or otherwise). Display companions are preserved. + * The caller passes a cloned object. + */ +export function redactConfigSecrets(root: T): T { + const rootRecord = getPlainRecord(root); + if (!rootRecord) { + return root; + } + delete rootRecord[LANGFUSE_SECRET_PATH]; + delete rootRecord[LANGFUSE_DISPLAY_SECRET_PATH]; + if (Array.isArray(rootRecord[LANGFUSE_SECTION])) { + delete rootRecord[LANGFUSE_SECTION]; + return root; + } + const section = getPlainRecord(rootRecord[LANGFUSE_SECTION]); + if (section) { + delete section[LANGFUSE_SECRET_KEY]; + } + return root; +} diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index b224c714dd..5f8fc78eda 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1,4 +1,4 @@ -import { logger } from '@librechat/data-schemas'; +import { encryptV3, logger } from '@librechat/data-schemas'; import { EModelEndpoint, FileSources, @@ -48,6 +48,13 @@ jest.mock('~/utils/env', () => ({ jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), + decryptV3: jest.fn((value: string) => { + if (value === 'v3:test:sk-tenant-1') { + return 'sk-tenant-1'; + } + throw new Error('bad decrypt'); + }), + encryptV3: jest.fn((value: string) => `v3:test:${value}`), logger: { debug: jest.fn(), warn: jest.fn(), @@ -215,7 +222,6 @@ beforeEach(() => { delete process.env.LANGFUSE_HOST; delete process.env.LANGFUSE_FANOUT_ENABLED; delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL; - delete process.env.LANGFUSE_FANOUT_TENANT_BASE_URL; delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS; delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED; }); @@ -1190,16 +1196,17 @@ describe('Langfuse run config', () => { }); it('adds tenant Langfuse credentials from tenant-scoped app config', async () => { + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318'; + const callArgs = await callAndCaptureRunConfig({ tenantId: 'tenant-1', appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', fanout: { enabled: true, - collectorUrl: 'http://langfuse-fanout-collector:4318', }, }, } as unknown as AppConfig, @@ -1229,8 +1236,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1251,14 +1258,13 @@ describe('Langfuse run config', () => { process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example'; process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'https://cloud.langfuse.com'; const callArgs = await callAndCaptureRunConfig({ tenantId: 'tenant-1', appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', + secretKey: encryptV3('sk-tenant-1'), }, } as AppConfig, }); @@ -1271,7 +1277,7 @@ describe('Langfuse run config', () => { }); }); - it('routes tenant fanout traces to the configured destination for the tenant base URL', async () => { + it('routes tenant fanout traces to the configured tenant destination', async () => { process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; @@ -1280,8 +1286,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://us.cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'us', }, } as AppConfig, }); @@ -1307,8 +1313,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1329,8 +1335,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://us.cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'us', }, } as AppConfig, }); @@ -1361,8 +1367,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1390,8 +1396,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1418,8 +1424,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1434,7 +1440,7 @@ describe('Langfuse run config', () => { }); }); - it('uses deployment fanout collector URL without auth when the tenant base URL is not a configured destination', async () => { + it('uses deployment fanout collector URL without auth when the tenant destination is not configured', async () => { process.env.LANGFUSE_PUBLIC_KEY = 'pk-central'; process.env.LANGFUSE_SECRET_KEY = 'sk-central'; process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example'; @@ -1447,8 +1453,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://unconfigured-langfuse.example.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'unconfigured', }, } as AppConfig, }); @@ -1515,7 +1521,7 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', + secretKey: encryptV3('sk-tenant-1'), }, } as AppConfig, }); @@ -1540,8 +1546,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1574,8 +1580,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1603,8 +1609,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', }, } as AppConfig, }); @@ -1636,8 +1642,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', fanout: { enabled: false, }, @@ -1667,8 +1673,8 @@ describe('Langfuse run config', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', fanout: { enabled: 'false', }, @@ -1693,7 +1699,7 @@ describe('Langfuse run config', () => { langfuse: { enabled: false, publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', + secretKey: encryptV3('sk-tenant-1'), }, } as AppConfig, }); @@ -1713,7 +1719,7 @@ describe('Langfuse run config', () => { langfuse: { enabled: 'false', publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', + secretKey: encryptV3('sk-tenant-1'), }, } as unknown as AppConfig, }); diff --git a/packages/api/src/langfuse/config.spec.ts b/packages/api/src/langfuse/config.spec.ts index 942761f1b5..5e42f15e71 100644 --- a/packages/api/src/langfuse/config.spec.ts +++ b/packages/api/src/langfuse/config.spec.ts @@ -1,40 +1,120 @@ import type { AppConfig } from '@librechat/data-schemas'; -jest.mock('@librechat/data-schemas', () => ({ - logger: { - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - info: jest.fn(), - }, -})); - -import { buildLangfuseConfig } from './config'; +process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; const CENTRAL_EXPORT_ATTRIBUTE = 'librechat.langfuse.central_export.enabled'; +const envKeys = [ + 'LANGFUSE_PUBLIC_KEY', + 'LANGFUSE_SECRET_KEY', + 'LANGFUSE_BASE_URL', + 'LANGFUSE_HOST', + 'LANGFUSE_BASEURL', + 'LANGFUSE_FANOUT_ENABLED', + 'LANGFUSE_FANOUT_COLLECTOR_URL', + 'LANGFUSE_FANOUT_TENANT_DESTINATIONS', + 'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED', +]; -function clearLangfuseEnv() { - delete process.env.LANGFUSE_PUBLIC_KEY; - delete process.env.LANGFUSE_SECRET_KEY; - delete process.env.LANGFUSE_BASE_URL; - delete process.env.LANGFUSE_BASEURL; - delete process.env.LANGFUSE_HOST; - delete process.env.LANGFUSE_FANOUT_ENABLED; - delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL; - delete process.env.LANGFUSE_FANOUT_TENANT_BASE_URL; - delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS; - delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED; +function clearEnv() { + for (const key of envKeys) { + delete process.env[key]; + } } -describe('buildLangfuseConfig central export control', () => { +describe('buildLangfuseConfig', () => { beforeEach(() => { - clearLangfuseEnv(); + clearEnv(); }); - it('keeps central export enabled by default', () => { + afterEach(() => { + clearEnv(); + }); + + it('decrypts encrypted tenant secrets for tenant trace export', async () => { + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318'; + const { encryptV3 } = await import('@librechat/data-schemas'); + const { buildLangfuseConfig } = await import('./config'); + + const config = buildLangfuseConfig({ + tenantId: 'tenant-1', + appConfig: { + langfuse: { + publicKey: 'pk-tenant-1', + secretKey: encryptV3('sk-tenant-1'), + destination: 'eu', + fanout: { + enabled: true, + }, + }, + } as unknown as AppConfig, + }); + + expect(config).toMatchObject({ + publicKey: 'pk-tenant-1', + secretKey: 'sk-tenant-1', + baseUrl: 'http://langfuse-fanout-collector:4318/tenant/eu', + librechatTraceAttributes: { + 'librechat.langfuse.tenant_export.enabled': 'true', + 'librechat.langfuse.destination': 'eu', + }, + }); + }); + + it('fails closed to central-only export when tenant secret decryption fails', async () => { + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318'; + const { buildLangfuseConfig } = await import('./config'); + + const config = buildLangfuseConfig({ + tenantId: 'tenant-1', + appConfig: { + langfuse: { + publicKey: 'pk-tenant-1', + secretKey: 'v3:not-valid-ciphertext', + destination: 'eu', + }, + } as unknown as AppConfig, + }); + + expect(config).toEqual({ + deterministicTraceId: true, + baseUrl: 'http://langfuse-fanout-collector:4318', + metadata: { 'librechat.tenant.id': 'tenant-1' }, + tags: ['tenant:tenant-1'], + }); + }); + + it('fails closed to central-only export when tenant secret is plaintext at runtime', async () => { + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318'; + const { buildLangfuseConfig } = await import('./config'); + + const config = buildLangfuseConfig({ + tenantId: 'tenant-1', + appConfig: { + langfuse: { + publicKey: 'pk-tenant-1', + secretKey: 'sk-tenant-1', + destination: 'eu', + }, + } as unknown as AppConfig, + }); + + expect(config).toEqual({ + deterministicTraceId: true, + baseUrl: 'http://langfuse-fanout-collector:4318', + metadata: { 'librechat.tenant.id': 'tenant-1' }, + tags: ['tenant:tenant-1'], + }); + }); + + it('keeps central export enabled by default', async () => { process.env.LANGFUSE_PUBLIC_KEY = 'pk-central'; process.env.LANGFUSE_SECRET_KEY = 'sk-central'; process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example'; + const { buildLangfuseConfig } = await import('./config'); expect(buildLangfuseConfig({ tenantId: 'tenant-1' })).toEqual({ deterministicTraceId: true, @@ -46,10 +126,11 @@ describe('buildLangfuseConfig central export control', () => { }); }); - it('disables direct central tracing when central export is disabled', () => { + it('disables direct central tracing when central export is disabled', async () => { process.env.LANGFUSE_PUBLIC_KEY = 'pk-central'; process.env.LANGFUSE_SECRET_KEY = 'sk-central'; process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example'; + const { buildLangfuseConfig } = await import('./config'); expect( buildLangfuseConfig({ @@ -67,11 +148,12 @@ describe('buildLangfuseConfig central export control', () => { }); }); - it('does not emit central-suppressed traces when there is no tenant fanout route', () => { + it('does not emit central-suppressed traces when there is no tenant fanout route', async () => { process.env.LANGFUSE_PUBLIC_KEY = 'pk-central'; process.env.LANGFUSE_SECRET_KEY = 'sk-central'; process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; + const { buildLangfuseConfig } = await import('./config'); expect( buildLangfuseConfig({ @@ -89,9 +171,11 @@ describe('buildLangfuseConfig central export control', () => { }); }); - it('routes tenant fanout traces while marking central export disabled', () => { + it('routes encrypted tenant credentials while marking central export disabled', async () => { process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; + const { encryptV3 } = await import('@librechat/data-schemas'); + const { buildLangfuseConfig } = await import('./config'); expect( buildLangfuseConfig({ @@ -100,10 +184,10 @@ describe('buildLangfuseConfig central export control', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://us.cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'us', }, - } as AppConfig, + } as unknown as AppConfig, }), ).toMatchObject({ deterministicTraceId: true, @@ -120,10 +204,12 @@ describe('buildLangfuseConfig central export control', () => { }); }); - it('does not emit central-suppressed traces when tenant fanout is emergency-disabled', () => { + it('does not emit central-suppressed traces when tenant fanout is emergency-disabled', async () => { process.env.LANGFUSE_FANOUT_ENABLED = 'true'; process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318'; process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true'; + const { encryptV3 } = await import('@librechat/data-schemas'); + const { buildLangfuseConfig } = await import('./config'); expect( buildLangfuseConfig({ @@ -132,10 +218,10 @@ describe('buildLangfuseConfig central export control', () => { appConfig: { langfuse: { publicKey: 'pk-tenant-1', - secretKey: 'sk-tenant-1', - baseUrl: 'https://us.cloud.langfuse.com', + secretKey: encryptV3('sk-tenant-1'), + destination: 'us', }, - } as AppConfig, + } as unknown as AppConfig, }), ).toEqual({ deterministicTraceId: true, @@ -148,7 +234,9 @@ describe('buildLangfuseConfig central export control', () => { }); }); - it('honors tenant Langfuse enabled=false before adding routing attributes', () => { + it('honors tenant Langfuse enabled=false before adding routing attributes', async () => { + const { buildLangfuseConfig } = await import('./config'); + expect( buildLangfuseConfig({ tenantId: 'tenant-1', diff --git a/packages/api/src/langfuse/config.ts b/packages/api/src/langfuse/config.ts index 53e5472c02..c2452e5865 100644 --- a/packages/api/src/langfuse/config.ts +++ b/packages/api/src/langfuse/config.ts @@ -1,14 +1,12 @@ import type { AppConfig } from '@librechat/data-schemas'; import type { RunConfig } from '@librechat/agents'; +import { isTrueEnv, normalizeBoolean, resolveTenantCredentials } from './utils'; import { resolveLangfuseTenantDestination } from './tenantDestinations'; -import { isTrueEnv, normalizeBoolean } from './utils'; import { normalizeString } from '~/utils/text'; type LangfuseRunConfig = NonNullable; type LangfuseAppConfig = NonNullable; -export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'] & { - collectorUrl?: string; -}; +export type LangfuseFanoutConfig = LangfuseAppConfig['fanout']; type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & { librechatTraceAttributes?: Record; }; @@ -166,23 +164,20 @@ export function buildLangfuseConfig({ disableCentralExport(langfuse); } - const publicKey = normalizeString(config?.publicKey); - const secretKey = normalizeString(config?.secretKey); - const hasTenantCredentials = Boolean(publicKey && secretKey); + const tenantCredentials = resolveTenantCredentials(config); + const hasTenantCredentials = Boolean(tenantCredentials); const fanout = config?.fanout as LangfuseFanoutConfig | undefined; const fanoutEnabled = isLangfuseFanoutEnabled(fanout); - const fanoutCollectorUrl = - normalizeString(fanout?.collectorUrl) ?? - normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL); + const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL); + const tenantDestination = resolveLangfuseTenantDestination(config?.destination); const tenantExportEmergencyEnabled = isLangfuseTenantExportEnabled(); - const tenantDestination = resolveLangfuseTenantDestination(config?.baseUrl); const exportPlan = resolveLangfuseExportPlan({ centralTraceExportEnabled, fanoutEnabled, fanoutCollectorUrl, tenantExportEnabled: hasTenantCredentials && tenantExportEmergencyEnabled, - publicKey, - secretKey, + publicKey: tenantCredentials?.publicKey, + secretKey: tenantCredentials?.secretKey, tenantDestination, }); diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index 0b0bc4dd45..ad5e09bd1f 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -1,7 +1,12 @@ import type { AppConfig } from '@librechat/data-schemas'; import type { LangfuseFanoutConfig } from './config'; +import { + isFalseEnv, + normalizeBoolean, + resolveTenantCredentials, + toBasicAuthorization, +} from './utils'; import { isLangfuseFanoutEnabled, isLangfuseTenantExportEnabled } from './config'; -import { isFalseEnv, normalizeBoolean, toBasicAuthorization } from './utils'; import { resolveLangfuseTenantDestination } from './tenantDestinations'; import { normalizeString } from '~/utils/text'; @@ -74,19 +79,16 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat if (!isLangfuseFanoutEnabled(fanout)) { return undefined; } - const fanoutCollectorUrl = - normalizeString(fanout?.collectorUrl) ?? - normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL); + const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL); if (!fanoutCollectorUrl) { return undefined; } - const publicKey = normalizeString(config?.publicKey); - const secretKey = normalizeString(config?.secretKey); - if (!publicKey || !secretKey) { + const tenantCredentials = resolveTenantCredentials(config); + if (!tenantCredentials) { return undefined; } - const destination = resolveLangfuseTenantDestination(config?.baseUrl); + const destination = resolveLangfuseTenantDestination(config?.destination); if (!destination) { return undefined; } @@ -94,13 +96,13 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat return { name: 'tenant', baseUrl: destination.baseUrl, - authorization: toBasicAuthorization(publicKey, secretKey), + authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey), }; } /** - * Score fanout uses Langfuse's direct REST API. Trace fanout may use the OTLP - * collector via appConfig.langfuse.fanout.collectorUrl/LANGFUSE_FANOUT_COLLECTOR_URL. + * Score fanout uses Langfuse's direct REST API. The deployment-level collector + * URL is still required so tenant score fanout follows trace fanout availability. */ export function getScoreDestinations(appConfig?: AppConfig): LangfuseScoreDestination[] { const destinations = [getCentralScoreDestination(), getTenantScoreDestination(appConfig)].filter( diff --git a/packages/api/src/langfuse/feedback.spec.ts b/packages/api/src/langfuse/feedback.spec.ts index 75278d38df..075d0b10f5 100644 --- a/packages/api/src/langfuse/feedback.spec.ts +++ b/packages/api/src/langfuse/feedback.spec.ts @@ -1,16 +1,33 @@ import type { AppConfig } from '@librechat/data-schemas'; +process.env.CREDS_KEY = + process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + jest.mock( '@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), error: jest.fn(), + warn: jest.fn(), }, + decryptV3: jest.fn((value: string) => { + if (value === 'v3:test:tenant-secret-key') { + return 'tenant-secret-key'; + } + throw new Error('bad decrypt'); + }), + encryptV3: jest.fn((value: string) => `v3:test:${value}`), }), { virtual: true }, ); +jest.mock('~/admin/secrets', () => ({ + decryptConfigSecret: jest.fn((value: string) => + value === 'v3:test:tenant-secret-key' ? 'tenant-secret-key' : undefined, + ), +})); + const langfuseEnvKeys = [ 'LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', @@ -22,7 +39,6 @@ const langfuseEnvKeys = [ 'LANGFUSE_TRACING_ENVIRONMENT', 'LANGFUSE_FANOUT_ENABLED', 'LANGFUSE_FANOUT_COLLECTOR_URL', - 'LANGFUSE_FANOUT_TENANT_BASE_URL', 'LANGFUSE_FANOUT_TENANT_DESTINATIONS', 'LANGFUSE_FANOUT_TENANT_EU_BASE_URL', 'LANGFUSE_FANOUT_TENANT_US_BASE_URL', @@ -63,6 +79,10 @@ function getTenantAuthorization( return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`; } +function encryptedTenantSecret(): string { + return 'v3:test:tenant-secret-key'; +} + function getCentralAuthorization(): string { return getTenantAuthorization('public-key', 'secret-key'); } @@ -154,7 +174,7 @@ describe('Langfuse feedback scores', () => { it('posts feedback scores to central fanout and tenant Langfuse projects', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; const { sendFeedbackScore } = await loadFeedback(); await sendFeedbackScore({ @@ -164,8 +184,8 @@ describe('Langfuse feedback scores', () => { appConfig: { langfuse: { publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }, } as AppConfig, }); @@ -205,10 +225,10 @@ describe('Langfuse feedback scores', () => { }); }); - it('skips tenant feedback scores when tenant keys are configured without a tenant base URL', async () => { + it('decrypts encrypted tenant secrets before sending tenant feedback scores', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; const { sendFeedbackScore } = await loadFeedback(); await sendFeedbackScore({ @@ -216,7 +236,64 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', + secretKey: encryptedTenantSecret(), + destination: 'eu', + }), + }); + + expect(getFetchMock()).toHaveBeenCalledTimes(2); + expect(getFetchMock()).toHaveBeenNthCalledWith( + 2, + 'http://tenant-langfuse:3000/api/public/scores', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: getTenantAuthorization(), + }), + }), + ); + }); + + it('skips tenant feedback scores when encrypted tenant secret decryption fails', async () => { + enableTenantFanout(); + process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: 'trace-id', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + publicKey: 'tenant-public-key', + secretKey: 'v3:test:bad-secret', + destination: 'eu', + }), + }); + + expect(getFetchMock()).toHaveBeenCalledTimes(1); + expect(getFetchMock()).toHaveBeenCalledWith( + 'http://central-langfuse:3000/api/public/scores', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: getCentralAuthorization(), + }), + }), + ); + }); + + it('skips tenant feedback scores when tenant keys are configured without a tenant destination', async () => { + enableTenantFanout(); + process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: 'trace-id', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + publicKey: 'tenant-public-key', + secretKey: encryptedTenantSecret(), }), }); @@ -230,7 +307,7 @@ describe('Langfuse feedback scores', () => { ); }); - it('posts tenant feedback scores to the configured destination for the tenant base URL', async () => { + it('posts tenant feedback scores to the configured tenant destination', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; const { sendFeedbackScore } = await loadFeedback(); @@ -240,8 +317,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://us.cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'us', }), }); @@ -257,7 +334,7 @@ describe('Langfuse feedback scores', () => { ); }); - it('skips tenant feedback scores when the tenant base URL is not a configured destination', async () => { + it('skips tenant feedback scores when the tenant destination is not configured', async () => { process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=https://cloud.langfuse.com'; const { sendFeedbackScore } = await loadFeedback(); @@ -267,8 +344,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://unconfigured-langfuse.example.com', + secretKey: encryptedTenantSecret(), + destination: 'unconfigured', }), }); @@ -285,7 +362,7 @@ describe('Langfuse feedback scores', () => { it('deletes feedback scores from central and tenant Langfuse projects', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; const { sendFeedbackScore } = await loadFeedback(); await sendFeedbackScore({ @@ -294,8 +371,8 @@ describe('Langfuse feedback scores', () => { appConfig: { langfuse: { publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }, } as AppConfig, }); @@ -325,7 +402,7 @@ describe('Langfuse feedback scores', () => { enableTenantFanout(); delete process.env.LANGFUSE_PUBLIC_KEY; delete process.env.LANGFUSE_SECRET_KEY; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; const { sendFeedbackScore } = await loadFeedback(); await sendFeedbackScore({ @@ -333,8 +410,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -359,7 +436,7 @@ describe('Langfuse feedback scores', () => { appConfig: appConfigWithLangfuse({ enabled: false, publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', + secretKey: encryptedTenantSecret(), }), }); @@ -384,8 +461,8 @@ describe('Langfuse feedback scores', () => { appConfig: appConfigWithLangfuse({ enabled: 'false', publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', } as unknown as AppConfig['langfuse']), }); @@ -402,7 +479,7 @@ describe('Langfuse feedback scores', () => { it('skips tenant scores when tenant fanout export is disabled but keeps central scores', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true'; const { sendFeedbackScore } = await loadFeedback(); @@ -411,7 +488,7 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', + secretKey: encryptedTenantSecret(), }), }); @@ -436,8 +513,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -471,8 +548,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -500,8 +577,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -525,8 +602,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -550,8 +627,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', fanout: { enabled: false }, }), }); @@ -576,8 +653,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', fanout: { enabled: 'false' }, } as unknown as AppConfig['langfuse']), }); @@ -602,8 +679,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -629,8 +706,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -644,7 +721,7 @@ describe('Langfuse feedback scores', () => { it('attempts every destination and reports partial feedback score failures', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; fetchMock .mockResolvedValueOnce(new Response('central down', { status: 500 })) .mockResolvedValueOnce(new Response(null, { status: 200 })); @@ -657,8 +734,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }), ).rejects.toThrow('langfuse central score create failed: score create 500: central down'); @@ -673,7 +750,7 @@ describe('Langfuse feedback scores', () => { it('reports tenant feedback score failures after central succeeds', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; fetchMock .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response('tenant down', { status: 503 })); @@ -686,8 +763,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }), ).rejects.toThrow('langfuse tenant score create failed: score create 503: tenant down'); @@ -705,7 +782,7 @@ describe('Langfuse feedback scores', () => { it('aggregates feedback score failures when every destination fails', async () => { enableTenantFanout(); process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000'; - process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000'; + process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000'; fetchMock .mockResolvedValueOnce(new Response('central down', { status: 500 })) .mockResolvedValueOnce(new Response('tenant down', { status: 503 })); @@ -717,8 +794,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'http://tenant-langfuse:3000', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }), ).rejects.toThrow( @@ -756,8 +833,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); @@ -785,8 +862,8 @@ describe('Langfuse feedback scores', () => { feedback: { rating: 'thumbsUp' }, appConfig: appConfigWithLangfuse({ publicKey: 'tenant-public-key', - secretKey: 'tenant-secret-key', - baseUrl: 'https://cloud.langfuse.com', + secretKey: encryptedTenantSecret(), + destination: 'eu', }), }); diff --git a/packages/api/src/langfuse/tenantDestinations.ts b/packages/api/src/langfuse/tenantDestinations.ts index 85a733aede..6ba3272235 100644 --- a/packages/api/src/langfuse/tenantDestinations.ts +++ b/packages/api/src/langfuse/tenantDestinations.ts @@ -7,7 +7,6 @@ const DEFAULT_TENANT_DESTINATIONS: Array<[string, string]> = [ ]; const DESTINATIONS_ENV = 'LANGFUSE_FANOUT_TENANT_DESTINATIONS'; -const LEGACY_TENANT_BASE_URL_ENV = 'LANGFUSE_FANOUT_TENANT_BASE_URL'; export type LangfuseTenantDestination = { key: string; @@ -84,28 +83,26 @@ export function getLangfuseTenantDestinations(): LangfuseTenantDestination[] { return uniqueDestinations(configured); } - const legacyBaseUrl = normalizeBaseUrl(process.env[LEGACY_TENANT_BASE_URL_ENV]); const defaults = DEFAULT_TENANT_DESTINATIONS.map(([key, defaultBaseUrl]) => ({ key, - baseUrl: - normalizeBaseUrl(process.env[destinationEnvName(key)]) ?? - (key === 'eu' ? legacyBaseUrl : undefined) ?? - defaultBaseUrl, + baseUrl: normalizeBaseUrl(process.env[destinationEnvName(key)]) ?? defaultBaseUrl, })); return uniqueDestinations(defaults); } export function resolveLangfuseTenantDestination( - baseUrl: unknown, + destinationKey: unknown, ): LangfuseTenantDestination | undefined { - const normalizedBaseUrl = normalizeBaseUrl(baseUrl); - - if (!normalizedBaseUrl) { + const normalizedKey = normalizeString(destinationKey); + if (!normalizedKey) { return undefined; } - return getLangfuseTenantDestinations().find( - (destination) => destination.baseUrl === normalizedBaseUrl, - ); + const key = normalizeDestinationKey(normalizedKey); + if (!key) { + return undefined; + } + + return getLangfuseTenantDestinations().find((destination) => destination.key === key); } diff --git a/packages/api/src/langfuse/utils.ts b/packages/api/src/langfuse/utils.ts index 064b1e9d80..aefc093e38 100644 --- a/packages/api/src/langfuse/utils.ts +++ b/packages/api/src/langfuse/utils.ts @@ -1,7 +1,24 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { decryptConfigSecret } from '~/admin/secrets'; +import { normalizeString } from '~/utils/text'; + +type LangfuseAppConfig = NonNullable; + export function toBasicAuthorization(publicKey: string, secretKey: string): string { return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`; } +export function resolveTenantCredentials( + config?: LangfuseAppConfig, +): { publicKey: string; secretKey: string } | undefined { + const publicKey = normalizeString(config?.publicKey); + const secretKey = decryptConfigSecret(config?.secretKey); + if (!publicKey || !secretKey) { + return undefined; + } + return { publicKey, secretKey }; +} + const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 203ac3b023..f710a3692f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1844,11 +1844,14 @@ export const langfuseConfigSchema = z.object({ enabled: z.boolean().optional(), publicKey: z.string().optional(), secretKey: z.string().optional(), - baseUrl: z.string().optional(), + /** Non-secret display value of the secret key, stored at write time so + * admin reads can show which secret key is configured without returning the secret. */ + displaySecretKey: z.string().optional(), + /** Routing key for one of the deployment-configured tenant Langfuse destinations. */ + destination: z.string().optional(), fanout: z .object({ enabled: z.boolean().optional(), - collectorUrl: z.string().optional(), }) .optional(), });