refactor(api): simplify langfuse config secret handling

This commit is contained in:
Ravi Kumar L 2026-07-06 16:16:09 +02:00
parent e72a8ae602
commit 26f6c70936
4 changed files with 274 additions and 465 deletions

View file

@ -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({

View file

@ -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<string, unknown> {
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<string, unknown>).langfuse,
);
if (langfuseInputError) {
return res.status(400).json({ error: langfuseInputError });
}
const encryptedOverrides = encryptConfigSecrets(filteredOverrides);
const needsSecretPreservation = Object.entries(
encryptedOverrides as Record<string, unknown>,
).some(([fieldPath, value]) => isObjectValuedSecretAncestor(fieldPath, value));
const existingForSecrets = needsSecretPreservation
const existingForSecrets = isObjectValuedLangfusePatch(
'langfuse',
(filteredOverrides as Record<string, unknown>).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) {

View file

@ -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<string, string>;
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<string, string | boolean>;
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<string, string>;
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();
});
});

View file

@ -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<string>(['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<string, string> = {
'langfuse.secretKey': 'langfuse.displaySecretKey',
};
const SECRET_PATHS_BY_DISPLAY_SECRET: Record<string, string> = 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<string, unknown> | null {
return isPlainObject(value) ? (value as Record<string, unknown>) : null;
}
function getLangfuseSection(root: unknown, basePath = ''): Record<string, unknown> | 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<string, unknown> {
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<string, unknown> = 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<string, unknown>)[segment];
}
return cursor;
}
function setNestedValue(root: unknown, path: string, value: unknown): void {
const segments = path.split('.');
let cursor = root as Record<string, unknown>;
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<string, unknown>;
}
cursor[segments[segments.length - 1]] = value;
}
function deleteNestedValue(root: unknown, path: string): void {
const segments = path.split('.');
let cursor = root as Record<string, unknown>;
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<string, unknown>;
}
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<string, unknown>): boolean {
if (Array.isArray(root[LANGFUSE_SECTION])) {
delete root[LANGFUSE_SECTION];
return true;
}
return false;
}
function applyLangfuseSecretWrite(section: Record<string, unknown>): 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<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...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<T>(root: T, basePath = ''): T {
if (root == null || typeof root !== 'object') {
@ -217,64 +151,23 @@ export function encryptConfigSecrets<T>(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<string, unknown>)[LANGFUSE_SECRET_PATH];
delete (result as Record<string, unknown>)[LANGFUSE_DISPLAY_SECRET_PATH];
removeLangfuseArraySection(result as Record<string, unknown>);
}
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<T>(next: T, existing?: unknown, basePath = ''): T {
if (
@ -287,57 +180,47 @@ export function preserveConfigSecrets<T>(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<T>(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;
}