mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
refactor(api): simplify langfuse config secret handling
This commit is contained in:
parent
e72a8ae602
commit
26f6c70936
4 changed files with 274 additions and 465 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue