diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index b6d01bcace..d802c31f46 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -399,6 +399,27 @@ describe('createAdminConfigHandlers', () => { }); }); + 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) => ({ @@ -629,7 +650,7 @@ describe('createAdminConfigHandlers', () => { ); }); - it('also deletes the secret when deleting its displayed secret key', async () => { + it('rejects deletes of the displayed secret key', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, @@ -639,13 +660,8 @@ describe('createAdminConfigHandlers', () => { 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', - ); + expect(res.statusCode).toBe(400); + expect(deps.unsetConfigField).not.toHaveBeenCalled(); }); it('returns 400 when fieldPath query param is missing', async () => { @@ -745,7 +761,7 @@ describe('createAdminConfigHandlers', () => { ); }); - it('also tombstones the secret when tombstoning its displayed secret key', async () => { + it('rejects tombstones of the displayed secret key', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, @@ -755,21 +771,8 @@ describe('createAdminConfigHandlers', () => { 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, - ); + expect(res.statusCode).toBe(400); + expect(deps.tombstoneConfigField).not.toHaveBeenCalled(); }); it('blocks interface permission paths', async () => { @@ -1015,10 +1018,7 @@ describe('createAdminConfigHandlers', () => { const req = mockReq({ params: { principalType: 'role', principalId: 'admin' }, body: { - entries: [ - { fieldPath: 'langfuse.secretKey', value: { hidden: 'sk-lf-secret' } }, - { fieldPath: 'langfuse.displaySecretKey', value: 'spoofed' }, - ], + entries: [{ fieldPath: 'langfuse.secretKey', value: { hidden: 'sk-lf-secret' } }], }, }); const res = mockRes(); @@ -1031,6 +1031,38 @@ describe('createAdminConfigHandlers', () => { 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({ diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 197968ce41..f1e3558f0d 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -16,6 +16,7 @@ import { encryptConfigSecretFields, encryptConfigSecrets, getConfigSecretMutationPaths, + getConfigSecretInputError, isConfigSecretAncestorPath, isConfigSecretDescendantPath, preserveConfigSecrets, @@ -191,7 +192,7 @@ function redactConfigForResponse(config: IConfig): IConfig { } function redactAppConfigForResponse(appConfig: AppConfig): AppConfig { - const safeConfig = structuredClone(appConfig) as AppConfig & { config?: unknown }; + const safeConfig = JSON.parse(JSON.stringify(appConfig)) as AppConfig & { config?: unknown }; redactConfigSecrets(safeConfig); if (safeConfig.config != null && typeof safeConfig.config === 'object') { redactConfigSecrets(safeConfig.config); @@ -199,7 +200,7 @@ function redactAppConfigForResponse(appConfig: AppConfig): AppConfig { return safeConfig; } -function isObjectValuedSecretAncestor(fieldPath: string, value: unknown): boolean { +function isObjectValuedLangfusePatch(fieldPath: string, value: unknown): boolean { return ( isConfigSecretAncestorPath(fieldPath) && value != null && @@ -214,7 +215,7 @@ function preservePatchedConfigSecretFields( ): Record { const result = { ...fields }; for (const [fieldPath, value] of Object.entries(result)) { - if (isObjectValuedSecretAncestor(fieldPath, value)) { + if (isObjectValuedLangfusePatch(fieldPath, value)) { result[fieldPath] = preserveConfigSecrets(value, existingOverrides, fieldPath); } } @@ -453,11 +454,19 @@ 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 needsSecretPreservation = Object.entries( - encryptedOverrides as Record, - ).some(([fieldPath, value]) => isObjectValuedSecretAncestor(fieldPath, value)); - const existingForSecrets = needsSecretPreservation + const existingForSecrets = isObjectValuedLangfusePatch( + 'langfuse', + (filteredOverrides as Record).langfuse, + ) ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; const preservedOverrides = preserveConfigSecrets( @@ -533,6 +542,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { .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}`, @@ -599,12 +612,12 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { ); } const requestedPriority = hasBroadManage ? priority : undefined; - const needsSecretPreservation = Object.entries(fields).some(([fieldPath, value]) => - isObjectValuedSecretAncestor(fieldPath, value), - ); + const hasObjectValuedLangfusePatch = Object.entries(fields).some(([fieldPath, value]) => + isObjectValuedLangfusePatch(fieldPath, value), + ); const existing = - requestedPriority == null || needsSecretPreservation + requestedPriority == null || hasObjectValuedLangfusePatch ? await findConfigByPrincipal(principalType, principalId, { includeInactive: true }) : null; const encryptedFields = encryptConfigSecretFields(fields); @@ -661,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) { @@ -744,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) { diff --git a/packages/api/src/admin/secrets.spec.ts b/packages/api/src/admin/secrets.spec.ts index d21a985241..0296f38385 100644 --- a/packages/api/src/admin/secrets.spec.ts +++ b/packages/api/src/admin/secrets.spec.ts @@ -3,9 +3,10 @@ process.env.CREDS_KEY = // 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 decryptConfigSecret: typeof import('./secrets').decryptConfigSecret; +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; @@ -15,16 +16,16 @@ beforeAll(async () => { decryptConfigSecret, encryptConfigSecretFields, encryptConfigSecrets, + getConfigSecretInputError, preserveConfigSecrets, redactConfigSecrets, } = await import('./secrets')); ({ decryptV3 } = await import('@librechat/data-schemas')); }); -describe('encryptConfigSecretFields', () => { - it('encrypts a registered secret field and stores a display secret key', () => { +describe('Langfuse config secrets', () => { + it('encrypts direct field writes and stores a display secret key', () => { const out = encryptConfigSecretFields({ - 'langfuse.enabled': true, 'langfuse.publicKey': 'pk-lf-1', 'langfuse.secretKey': 'sk-lf-secret', }); @@ -32,145 +33,58 @@ describe('encryptConfigSecretFields', () => { 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.enabled']).toBe(true); expect(out['langfuse.publicKey']).toBe('pk-lf-1'); }); - it('resets already-encrypted API values and stale display secret keys', () => { - const second = encryptConfigSecretFields({ - 'langfuse.secretKey': 'v3:attacker-controlled', - 'langfuse.displaySecretKey': 'sk-old...-old', - }); - - expect(second['langfuse.secretKey']).toBe(''); - expect(second['langfuse.displaySecretKey']).toBe(''); - }); - - it('resets the secret and displaySecretKey for an empty secret', () => { - const out = encryptConfigSecretFields({ 'langfuse.secretKey': '' }); - expect(out['langfuse.secretKey']).toBe(''); - expect(out['langfuse.displaySecretKey']).toBe(''); - }); - - it('encrypts registered secrets inside object-valued ancestor patches', () => { - const out = encryptConfigSecretFields({ + 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', }, }); - const langfuse = out.langfuse as Record; - expect(langfuse.secretKey).toMatch(/^v3:/); - expect(decryptV3(langfuse.secretKey)).toBe('sk-lf-secret'); - expect(langfuse.displaySecretKey).toBe('sk-lf-...cret'); + 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('drops array-valued ancestor patches for registered secrets', () => { - const out = encryptConfigSecretFields({ - langfuse: [{ secretKey: 'sk-lf-secret' }], + it('clears empty or non-string secret values', () => { + expect(encryptConfigSecretFields({ 'langfuse.secretKey': '' })).toEqual({ + 'langfuse.secretKey': '', + 'langfuse.displaySecretKey': '', }); - expect(out).not.toHaveProperty('langfuse'); - }); - - it('ignores direct displaySecretKey writes when no secret is patched', () => { - const out = encryptConfigSecretFields({ - 'langfuse.displaySecretKey': 'spoofed', - }); - - expect(out).not.toHaveProperty('langfuse.displaySecretKey'); - }); - - it('resets non-string secret values and ignores spoofed display secret keys', () => { - const out = encryptConfigSecretFields({ - 'langfuse.secretKey': { hidden: 'sk-lf-secret' }, - 'langfuse.displaySecretKey': 'spoofed', - }); - - expect(out['langfuse.secretKey']).toBe(''); - expect(out['langfuse.displaySecretKey']).toBe(''); - }); -}); - -describe('encryptConfigSecrets', () => { - it('encrypts a nested registered secret field and stores a display secret key', () => { - const out = encryptConfigSecrets({ - langfuse: { - enabled: true, - publicKey: 'pk-lf-1', - secretKey: 'sk-lf-secret', - }, - }); - - const langfuse = out.langfuse as Record; - expect(langfuse.secretKey).toMatch(/^v3:/); - expect(decryptV3(langfuse.secretKey as string)).toBe('sk-lf-secret'); - expect(langfuse.displaySecretKey).toBe('sk-lf-...cret'); - expect(langfuse.publicKey).toBe('pk-lf-1'); - }); - - it('resets a nested empty secret and stale displaySecretKey', () => { - const out = encryptConfigSecrets({ + expect( + encryptConfigSecrets({ + langfuse: { + secretKey: null, + displaySecretKey: 'spoofed', + }, + }), + ).toEqual({ langfuse: { secretKey: '', - displaySecretKey: 'sk-old...-old', + displaySecretKey: '', }, }); - - expect(out.langfuse.secretKey).toBe(''); - expect(out.langfuse.displaySecretKey).toBe(''); }); - it('removes orphaned nested display secret keys when the secret is absent', () => { - const out = encryptConfigSecrets({ - langfuse: { - publicKey: 'pk-lf-1', - displaySecretKey: 'spoofed', - }, - }); - - expect(out.langfuse).toEqual({ publicKey: 'pk-lf-1' }); + 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('resets nested non-string secret values and stale display secret keys', () => { - const out = encryptConfigSecrets({ - langfuse: { - secretKey: null, - displaySecretKey: 'spoofed', - }, - }); - - expect(out.langfuse).toEqual({ - secretKey: '', - displaySecretKey: '', - }); - }); - - it('drops literal dotted secret keys on full-object writes', () => { - const out = encryptConfigSecrets({ - 'langfuse.secretKey': 'sk-lf-secret', - 'langfuse.displaySecretKey': 'spoofed', - langfuse: { - publicKey: 'pk-lf-1', - }, - }); - - expect(out).not.toHaveProperty('langfuse.secretKey'); - expect(out).not.toHaveProperty('langfuse.displaySecretKey'); - expect(out.langfuse).toEqual({ publicKey: 'pk-lf-1' }); - }); - - it('drops array-shaped ancestors for registered secret paths', () => { - const out = encryptConfigSecrets({ - langfuse: [{ secretKey: 'sk-lf-secret' }], - }); - - expect(out).not.toHaveProperty('langfuse'); - }); -}); - -describe('decryptConfigSecret', () => { it('decrypts encrypted config secrets and rejects plaintext runtime values', () => { const encrypted = encryptConfigSecrets({ langfuse: { secretKey: 'sk-lf-secret' }, @@ -179,15 +93,10 @@ describe('decryptConfigSecret', () => { expect(decryptConfigSecret(encrypted)).toBe('sk-lf-secret'); expect(decryptConfigSecret(' sk-plaintext ')).toBeUndefined(); expect(decryptConfigSecret('')).toBeUndefined(); - }); - - it('returns undefined for undecryptable encrypted values', () => { expect(decryptConfigSecret('v3:not-valid-ciphertext')).toBeUndefined(); }); -}); -describe('preserveConfigSecrets', () => { - it('preserves an existing encrypted secret when a full object omits it', () => { + it('preserves existing encrypted secrets when object writes omit them', () => { const existing = encryptConfigSecrets({ langfuse: { publicKey: 'pk-old', @@ -209,51 +118,32 @@ describe('preserveConfigSecrets', () => { expect(preserved.langfuse.publicKey).toBe('pk-new'); }); - it('does not preserve existing plaintext secrets from stored config', () => { + it('does not preserve plaintext existing secrets or explicitly cleared secrets', () => { const next = encryptConfigSecrets({ langfuse: { publicKey: 'pk-new', }, }); - const preserved = preserveConfigSecrets(next, { + const fromPlaintext = preserveConfigSecrets(next, { langfuse: { publicKey: 'pk-old', secretKey: 'sk-plain-existing', }, }); - const preservedLangfuse = preserved.langfuse as Record; + expect(fromPlaintext.langfuse).toEqual({ publicKey: 'pk-new' }); - expect(preservedLangfuse).not.toHaveProperty('secretKey'); - expect(preservedLangfuse).not.toHaveProperty('displaySecretKey'); - expect(preservedLangfuse.publicKey).toBe('pk-new'); - }); - - it('does not preserve when the secret section is absent', () => { const existing = encryptConfigSecrets({ langfuse: { secretKey: 'sk-old', }, }); - - expect(preserveConfigSecrets({ interface: { modelSelect: false } }, existing)).toEqual({ - interface: { modelSelect: false }, - }); - }); - - it('does not preserve when the secret is explicitly cleared', () => { - const existing = encryptConfigSecrets({ - langfuse: { - secretKey: 'sk-old', - }, - }); - const next = encryptConfigSecrets({ + const cleared = encryptConfigSecrets({ langfuse: { secretKey: '', }, }); - - expect(preserveConfigSecrets(next, existing)).toEqual({ + expect(preserveConfigSecrets(cleared, existing)).toEqual({ langfuse: { secretKey: '', displaySecretKey: '', @@ -277,11 +167,11 @@ describe('preserveConfigSecrets', () => { expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey); expect(preserved.publicKey).toBe('pk-new'); }); -}); -describe('redactConfigSecrets', () => { - it('removes the secret but keeps the displaySecretKey and other fields', () => { + it('redacts secret values while preserving display secret keys', () => { const redacted = redactConfigSecrets({ + 'langfuse.secretKey': 'literal', + 'langfuse.displaySecretKey': 'literal-display', langfuse: { enabled: true, destination: 'eu', @@ -291,30 +181,13 @@ describe('redactConfigSecrets', () => { }, }); - expect(redacted.langfuse).not.toHaveProperty('secretKey'); - expect(redacted.langfuse.displaySecretKey).toBe('sk-lf-...cret'); - expect(redacted.langfuse.publicKey).toBe('pk-lf-1'); - expect(redacted.langfuse.enabled).toBe(true); - }); - - it('is a no-op when the secret path is absent', () => { - const input = { langfuse: { enabled: false } }; - expect(redactConfigSecrets(input)).toEqual({ langfuse: { enabled: false } }); - }); - - it('removes literal dotted secret keys and array-shaped secret containers', () => { - const redacted = redactConfigSecrets({ - 'langfuse.secretKey': 'sk-lf-secret', - 'langfuse.displaySecretKey': 'spoofed', - langfuseArray: true, - langfuse: [{ secretKey: 'sk-lf-secret' }], + 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', }); - - expect(redacted).toEqual({ langfuseArray: true }); - }); - - it('handles null/non-object roots', () => { - expect(redactConfigSecrets(null)).toBeNull(); - expect(redactConfigSecrets(undefined)).toBeUndefined(); }); }); diff --git a/packages/api/src/admin/secrets.ts b/packages/api/src/admin/secrets.ts index db8941ec16..49a4027962 100644 --- a/packages/api/src/admin/secrets.ts +++ b/packages/api/src/admin/secrets.ts @@ -1,27 +1,11 @@ +import isPlainObject from 'lodash/isPlainObject'; import { encryptV3, decryptV3, logger } from '@librechat/data-schemas'; -/** - * Dot-path config fields whose values are secrets. They are encrypted at rest - * before being written and are never returned by admin config reads. Add paths - * here to extend per-field encryption to other config sections. - */ -const ENCRYPTED_CONFIG_FIELD_PATHS = new Set(['langfuse.secretKey']); - -/** - * For each secret path, a sibling path holding a short non-secret display value. - * This mirrors Langfuse's API key UI: keep the first six and last four - * characters, and never return the full secret on reads. - */ -const DISPLAY_SECRET_PATHS: Record = { - 'langfuse.secretKey': 'langfuse.displaySecretKey', -}; -const SECRET_PATHS_BY_DISPLAY_SECRET: Record = Object.fromEntries( - Object.entries(DISPLAY_SECRET_PATHS).map(([secretPath, displaySecretPath]) => [ - displaySecretPath, - secretPath, - ]), -); - +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 { @@ -32,6 +16,25 @@ 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)) { @@ -46,170 +49,101 @@ export function decryptConfigSecret(value: unknown): string | undefined { } export function getConfigSecretMutationPaths(fieldPath: string): string[] { - const displaySecretPath = DISPLAY_SECRET_PATHS[fieldPath]; - if (displaySecretPath) { - return [fieldPath, displaySecretPath]; - } - const secretPath = SECRET_PATHS_BY_DISPLAY_SECRET[fieldPath]; - if (secretPath) { - return [secretPath, fieldPath]; + if (fieldPath === LANGFUSE_SECRET_PATH) { + return [LANGFUSE_SECRET_PATH, LANGFUSE_DISPLAY_SECRET_PATH]; } return [fieldPath]; } export function isConfigSecretDescendantPath(fieldPath: string): boolean { - const protectedPaths = [...ENCRYPTED_CONFIG_FIELD_PATHS, ...Object.values(DISPLAY_SECRET_PATHS)]; - return protectedPaths.some((path) => fieldPath.startsWith(`${path}.`)); + return ( + fieldPath.startsWith(`${LANGFUSE_SECRET_PATH}.`) || + fieldPath.startsWith(`${LANGFUSE_DISPLAY_SECRET_PATH}.`) + ); } export function isConfigSecretAncestorPath(fieldPath: string): boolean { - return [...ENCRYPTED_CONFIG_FIELD_PATHS].some((path) => path.startsWith(`${fieldPath}.`)); + return fieldPath === LANGFUSE_SECTION; } -function isRecord(value: unknown): value is Record { - return value != null && typeof value === 'object' && !Array.isArray(value); -} - -function deleteLiteralDottedKey(root: unknown, path: string): void { - if (!path.includes('.') || !isRecord(root)) { - return; +export function getConfigSecretInputError(fieldPath: string, value: unknown): string | null { + if (fieldPath === LANGFUSE_DISPLAY_SECRET_PATH) { + return `Cannot write protected display secret path: ${fieldPath}`; } - delete root[path]; -} - -function deleteArrayAncestor(root: unknown, path: string): void { - if (!isRecord(root)) { - return; + if (fieldPath === LANGFUSE_SECRET_PATH && isEncryptedConfigSecret(value)) { + return `Encrypted config secret values cannot be submitted: ${fieldPath}`; } - const segments = path.split('.'); - let cursor: Record = root; - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i]; - const next = cursor[segment]; - if (Array.isArray(next)) { - delete cursor[segment]; - return; - } - if (!isRecord(next)) { - return; - } - cursor = next; - } -} - -function getNestedValue(root: unknown, path: string): unknown { - const segments = path.split('.'); - let cursor = root; - for (const segment of segments) { - if (cursor == null || typeof cursor !== 'object') { - return undefined; - } - cursor = (cursor as Record)[segment]; - } - return cursor; -} - -function setNestedValue(root: unknown, path: string, value: unknown): void { - const segments = path.split('.'); - let cursor = root as Record; - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i]; - const next = cursor[segment]; - if (next == null || typeof next !== 'object' || Array.isArray(next)) { - cursor[segment] = {}; - } - cursor = cursor[segment] as Record; - } - cursor[segments[segments.length - 1]] = value; -} - -function deleteNestedValue(root: unknown, path: string): void { - const segments = path.split('.'); - let cursor = root as Record; - for (let i = 0; i < segments.length - 1; i++) { - const next = cursor[segments[i]]; - if (next == null || typeof next !== 'object') { - return; - } - cursor = next as Record; - } - delete cursor[segments[segments.length - 1]]; -} - -function getRelativeSecretPath(secretPath: string, basePath = ''): string | null { - if (basePath.length === 0) { - return secretPath; - } - if (secretPath.startsWith(`${basePath}.`)) { - return secretPath.slice(basePath.length + 1); + 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 secret-registered entries encrypted and their - * displaySecretKey companions set. Empty values reset the secret and displaySecretKey. + * 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 }; - for (const [fieldPath, fieldValue] of Object.entries(result)) { - if (!isConfigSecretAncestorPath(fieldPath)) { - continue; - } - if (Array.isArray(fieldValue)) { - delete result[fieldPath]; - continue; - } - if (fieldValue != null && typeof fieldValue === 'object') { - result[fieldPath] = encryptConfigSecrets(fieldValue, fieldPath); + + 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); } } - for (const path of ENCRYPTED_CONFIG_FIELD_PATHS) { - const value = result[path]; - const displaySecretPath = DISPLAY_SECRET_PATHS[path]; + if (!(LANGFUSE_SECRET_PATH in result) && LANGFUSE_DISPLAY_SECRET_PATH in result) { + delete result[LANGFUSE_DISPLAY_SECRET_PATH]; + } - if (!(path in result) && displaySecretPath && displaySecretPath in result) { - delete result[displaySecretPath]; - } - - if (value !== undefined && typeof value !== 'string') { - result[path] = ''; - if (displaySecretPath) { - result[displaySecretPath] = ''; - } - continue; - } - if (typeof value !== 'string') { - continue; - } - if (value.length === 0) { - result[path] = ''; - if (displaySecretPath) { - result[displaySecretPath] = ''; - } - continue; - } - if (value.startsWith(ENCRYPTED_PREFIX)) { - result[path] = ''; - if (displaySecretPath) { - result[displaySecretPath] = ''; - } - continue; - } - result[path] = encryptV3(value); - if (displaySecretPath) { - result[displaySecretPath] = getDisplaySecretKey(value); + 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 registered nested secret values - * encrypted before full-document writes. Empty secrets reset their displaySecretKey. + * 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') { @@ -217,64 +151,23 @@ export function encryptConfigSecrets(root: T, basePath = ''): T { } const result = structuredClone(root); - for (const path of ENCRYPTED_CONFIG_FIELD_PATHS) { - const relativePath = getRelativeSecretPath(path, basePath); - if (relativePath == null || relativePath.length === 0) { - continue; - } - const displaySecretPath = DISPLAY_SECRET_PATHS[path]; - const relativeDisplaySecretPath = displaySecretPath - ? getRelativeSecretPath(displaySecretPath, basePath) - : null; - deleteLiteralDottedKey(result, relativePath); - if (relativeDisplaySecretPath) { - deleteLiteralDottedKey(result, relativeDisplaySecretPath); - } - deleteArrayAncestor(result, relativePath); - const value = getNestedValue(result, relativePath); - if (value === undefined) { - if (relativeDisplaySecretPath) { - deleteNestedValue(result, relativeDisplaySecretPath); - } - continue; - } - if (value !== undefined && typeof value !== 'string') { - setNestedValue(result, relativePath, ''); - if (relativeDisplaySecretPath) { - setNestedValue(result, relativeDisplaySecretPath, ''); - } - continue; - } - if (typeof value !== 'string') { - continue; - } - if (value.length === 0) { - setNestedValue(result, relativePath, ''); - if (relativeDisplaySecretPath) { - setNestedValue(result, relativeDisplaySecretPath, ''); - } - continue; - } - if (value.startsWith(ENCRYPTED_PREFIX)) { - setNestedValue(result, relativePath, ''); - if (relativeDisplaySecretPath) { - setNestedValue(result, relativeDisplaySecretPath, ''); - } - continue; - } - setNestedValue(result, relativePath, encryptV3(value)); - if (relativeDisplaySecretPath) { - setNestedValue(result, relativeDisplaySecretPath, getDisplaySecretKey(value)); - } + 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 secret when a whole secret ancestor object is + * 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. + * 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 ( @@ -287,57 +180,47 @@ export function preserveConfigSecrets(next: T, existing?: unknown, basePath = } const result = structuredClone(next); - for (const path of ENCRYPTED_CONFIG_FIELD_PATHS) { - const relativePath = getRelativeSecretPath(path, basePath); - if (relativePath == null || relativePath.length === 0) { - continue; - } + 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 segments = relativePath.split('.'); - const leaf = segments[segments.length - 1]; - const parentPath = segments.slice(0, -1).join('.'); - const parent = parentPath ? getNestedValue(result, parentPath) : result; - if (!isRecord(parent) || leaf in parent) { - continue; - } - - const existingValue = normalizeSecretString(getNestedValue(existing, path)); - if (!existingValue || !existingValue.startsWith(ENCRYPTED_PREFIX)) { - continue; - } - - const displaySecretPath = DISPLAY_SECRET_PATHS[path]; - const relativeDisplaySecretPath = displaySecretPath - ? getRelativeSecretPath(displaySecretPath, basePath) - : null; - setNestedValue(result, relativePath, existingValue); - if (relativeDisplaySecretPath) { - const existingDisplaySecret = getNestedValue(existing, displaySecretPath); - if (typeof existingDisplaySecret === 'string') { - setNestedValue(result, relativeDisplaySecretPath, existingDisplaySecret); - } - } + 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 secret-registered 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. + * 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 { - if (root == null || typeof root !== 'object') { + const rootRecord = getPlainRecord(root); + if (!rootRecord) { return root; } - for (const path of ENCRYPTED_CONFIG_FIELD_PATHS) { - deleteLiteralDottedKey(root, path); - const displaySecretPath = DISPLAY_SECRET_PATHS[path]; - if (displaySecretPath) { - deleteLiteralDottedKey(root, displaySecretPath); - } - deleteArrayAncestor(root, path); - deleteNestedValue(root, path); + 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; }