LibreChat/api/server/routes/config.js
Dustin Healy af795be0c2
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
🪢 feat: Langfuse Fanout Connection Setting (#14108)
* feat: encrypt tenant Langfuse secret in admin config

Add generic per-field secret encryption to the admin config layer: registered
secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a
non-secret fingerprint companion is stored. Admin config reads (base + per
principal) redact registered secrets so they are never returned; the fingerprint
is kept so the UI can show which key is configured.

The Langfuse fanout read path decrypts the tenant secret before export. Adds
secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact
policy.

* fix(api): secure admin config secret handling

* fix(api): preserve encrypted langfuse config secrets

* fix(api): couple config secret fingerprint deletion

* fix(api): read langfuse fanout collector url from env

* fix(api): display langfuse secret key hint

* fix(api): remove langfuse secret fingerprint breadcrumbs

* fix(api): use langfuse destination keys for tenant config

* fix(api): remove langfuse config compatibility fallbacks

* refactor(api): simplify langfuse secret helpers

* refactor(api): simplify langfuse config secret handling

* feat: in-app Langfuse connection settings panel

Add a discoverable, admin-gated Langfuse connection panel inside LibreChat
Settings (Dify-style): enable toggle, host, public key, masked write-only secret,
configured-key fingerprint, and a test-connection action. Backed by a dedicated
/api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns
metadata plus fingerprint on read, and validates credentials. Builds on the
per-field encryption and fanout decrypt from the langfuse-config-encryption branch.

* refactor: align Langfuse secret field to CustomUserVars pattern

Use the established SecretInput plus Set/Unset state pill (com_ui_set/com_ui_unset)
from the MCP CustomUserVars UI for the saved-secret state, instead of a bespoke
masked input.

* fix: drop em dash from saved-secret placeholder

* feat: show loading state on Langfuse test connection button

* feat: gate in-app Langfuse settings on fanout config and admin role

* test: align Langfuse connection spec with SecretInput refactor

* feat(langfuse): refine tenant connection controls

* fix(admin): refine Langfuse connection verification

* fix(langfuse): refine tenant connection settings

* fix(langfuse): simplify export enablement controls

* fix(langfuse): validate tenant export configuration

* fix(langfuse): align startup fanout gate

* fix(admin): time out Langfuse verification

* fix(ui): rename Langfuse connection setting

* fix(admin): enforce Langfuse config capability

* feat(langfuse): require explicit tenant export activation

* feat(langfuse): support single-tenant connection settings

* fix(i18n): remove obsolete integrations label

* fix(langfuse): authenticate ingestion verification

* fix(langfuse): validate public key independently

* fix(langfuse): localize connection errors

* perf(config): skip Langfuse checks for non-admins

* fix(langfuse): preserve trace sampling for feedback

* test(langfuse): fix feedback sampling fixture

* fix(langfuse): align secret preview field

* fix(langfuse): harden connection settings state

* fix(langfuse): preserve trace destination state

* fix(langfuse): enforce tenant-wide routing invariants

* fix(langfuse): preserve verified connection invariants

* fix(langfuse): preserve stable project identity

* fix(langfuse): warm project identity asynchronously

---------

Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-07-29 18:33:10 -04:00

346 lines
12 KiB
JavaScript

const express = require('express');
const {
isEnabled,
isLangfuseConnectionAvailable,
isLangfuseFanoutEnabled,
getBalanceConfig,
getCloudFrontConfig,
getAppConfigOptionsFromUser,
resolveBuildInfo,
resolveTitleTiming,
sanitizeModelSpecs,
excludeHiddenModelSpecs,
isFileSnapshotEnabled,
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability, hasConfigCapability } = require('~/server/middleware/roles/capabilities');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getRumConfig } = require('~/server/services/Config/rum');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
const emailLoginEnabled =
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
const sharedLinksEnabled =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
const publicSharedLinksEnabled =
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Resolve build metadata eagerly at module load so the first `/api/config`
* request does not pay the cost of `execFileSync('git', ...)` on the hot path.
* The resolver caches its result after the first call.
*/
resolveBuildInfo();
function isBirthday() {
const today = new Date();
return today.getMonth() === 1 && today.getDate() === 11;
}
/**
* Pre-login fields rendered by the unauthenticated login, registration, password-reset,
* and email-verification pages. Any field added here is readable by anonymous callers
* of `GET /api/config`, so keep this set strictly to what those pages need.
*
* See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`.
*/
function buildPreLoginPayload() {
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
(isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) &&
!!process.env.OPENID_ISSUER &&
!!process.env.OPENID_SESSION_SECRET;
const isSamlEnabled =
!!process.env.SAML_ENTRY_POINT &&
!!process.env.SAML_ISSUER &&
!!process.env.SAML_CERT &&
!!process.env.SAML_SESSION_SECRET;
const ldap = getLdapConfig();
/** @type {Partial<TStartupConfig>} */
const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat',
discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
appleLoginEnabled:
!!process.env.APPLE_CLIENT_ID &&
!!process.env.APPLE_TEAM_ID &&
!!process.env.APPLE_KEY_ID &&
!!process.env.APPLE_PRIVATE_KEY_PATH,
openidLoginEnabled: isOpenIdEnabled,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL,
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
samlLabel: process.env.SAML_BUTTON_LABEL,
samlImageUrl: process.env.SAML_IMAGE_URL,
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
emailLoginEnabled,
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
emailEnabled:
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM,
passwordResetEnabled,
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
if (ldap) {
payload.ldap = ldap;
}
return payload;
}
/**
* Fields shared by authenticated chat and share-view config. Anonymous share
* views receive these through `/api/share/:shareId/config` after share access
* checks, not through the generic startup config endpoint.
*/
function buildPublicSharePayload() {
/** @type {Partial<TStartupConfig>} */
const payload = {
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
};
if (typeof process.env.CUSTOM_FOOTER === 'string') {
payload.customFooter = process.env.CUSTOM_FOOTER;
}
return payload;
}
/**
* Post-login fields appended only when `req.user` is present. These describe the
* authenticated UX (account-settings links, share-link feature flags, birthday icon,
* openid token-reuse marker) and are not needed on the pre-login screens, so they
* are not exposed to unauthenticated callers.
*/
function buildPostLoginPayload() {
/** @type {Partial<TStartupConfig>} */
const payload = {
showBirthdayIcon:
isBirthday() ||
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
sharedLinksEnabled,
publicSharedLinksEnabled,
openidReuseTokens,
/** Read inline (not module-level) for per-request evaluation and test isolation */
allowAccountDeletion:
process.env.ALLOW_ACCOUNT_DELETION === undefined ||
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
};
return payload;
}
function buildBuildInfoPayload(interfaceConfig) {
if (interfaceConfig?.buildInfo === false) {
return undefined;
}
const info = resolveBuildInfo();
if (!info.commit && !info.branch && !info.buildDate) {
return undefined;
}
return {
commit: info.commit,
commitShort: info.commitShort,
branch: info.branch,
buildDate: info.buildDate,
};
}
function buildWebSearchConfig(appConfig) {
const ws = appConfig?.webSearch;
if (!ws) {
return undefined;
}
const { searchProvider, scraperProvider, rerankerType } = ws;
if (!searchProvider && !scraperProvider && !rerankerType) {
return undefined;
}
return {
...(searchProvider && { searchProvider }),
...(scraperProvider && { scraperProvider }),
...(rerankerType && { rerankerType }),
};
}
function buildCloudFrontStartupConfig() {
const config = getCloudFrontConfig();
if (
config?.imageSigning !== 'cookies' ||
!config.domain ||
!config.cookieDomain ||
!config.privateKey ||
!config.keyPairId
) {
return undefined;
}
return {
cookieRefresh: {
endpoint: '/api/auth/cloudfront/refresh',
domain: config.domain,
},
};
}
router.get('/', async function (req, res) {
try {
const preLoginPayload = buildPreLoginPayload();
const publicSharePayload = buildPublicSharePayload();
const rum = getRumConfig();
if (!req.user) {
const tenantId = getTenantId();
const baseConfig = await getAppConfig(tenantId ? { tenantId } : { baseOnly: true });
/** @type {Partial<TStartupConfig>} */
const payload = {
...preLoginPayload,
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
...(rum ? { rum } : {}),
};
const interfaceConfig = baseConfig?.interfaceConfig;
const buildInfoDisabled = interfaceConfig?.buildInfo === false;
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) {
payload.interface = {};
if (interfaceConfig.privacyPolicy) {
payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
}
if (interfaceConfig.termsOfService) {
payload.interface.termsOfService = interfaceConfig.termsOfService;
}
if (buildInfoDisabled) {
payload.interface.buildInfo = false;
}
}
const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig);
if (unauthBuildInfo) {
payload.buildInfo = unauthBuildInfo;
}
return res.status(200).send(payload);
}
const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req.user));
const balanceConfig = getBalanceConfig(appConfig);
const cloudFront = buildCloudFrontStartupConfig();
const langfuseFanoutEnabled = isLangfuseFanoutEnabled();
const langfuseConnectionAvailable = isLangfuseConnectionAvailable();
let langfuseConnectionAccess = false;
if (langfuseConnectionAvailable) {
try {
const userId = req.user.id ?? req.user._id?.toString();
if (userId) {
const capabilityUser = {
id: userId,
role: req.user.role ?? '',
tenantId: req.user.tenantId,
idOnTheSource: req.user.idOnTheSource ?? null,
};
const hasAdminAccess = await hasCapability(
capabilityUser,
SystemCapabilities.ACCESS_ADMIN,
);
if (hasAdminAccess) {
langfuseConnectionAccess = await hasConfigCapability(capabilityUser, 'langfuse');
}
}
} catch (err) {
logger.warn(`[config] Langfuse capability check failed: ${err.message}`);
}
}
/** @type {TStartupConfig} */
const payload = {
...preLoginPayload,
...publicSharePayload,
...buildPostLoginPayload(),
sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig),
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
interface: appConfig?.interfaceConfig,
titleGenerationTiming: resolveTitleTiming({
appConfig,
endpoint: EModelEndpoint.agents,
}),
turnstile: appConfig?.turnstileConfig,
modelSpecs: sanitizeModelSpecs(excludeHiddenModelSpecs(appConfig?.modelSpecs)),
balance: balanceConfig,
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
sharePointFilePickerEnabled,
sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
langfuseFanoutEnabled,
langfuseConnectionAccess,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),
};
const webSearch = buildWebSearchConfig(appConfig);
if (webSearch) {
payload.webSearch = webSearch;
}
const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig);
if (buildInfo) {
payload.buildInfo = buildInfo;
}
if (!payload.allowAccountDeletion) {
try {
const userId = req.user.id ?? req.user._id?.toString();
if (userId) {
const canDelete = await hasCapability(
{ id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
SystemCapabilities.ACCESS_ADMIN,
);
if (canDelete) {
payload.allowAccountDeletion = true;
}
}
} catch (err) {
logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
}
}
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);
return res.status(500).send({ error: err.message });
}
});
module.exports = router;