const express = require('express'); const { isEnabled, getBalanceConfig, getCloudFrontConfig, resolveBuildInfo, resolveTitleTiming, sanitizeModelSpecs, isFileSnapshotEnabled, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability } = 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} */ 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} */ 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} */ 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} */ 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({ role: req.user.role, userId: req.user.id, tenantId: req.user.tenantId || getTenantId(), }); const balanceConfig = getBalanceConfig(appConfig); const cloudFront = buildCloudFrontStartupConfig(); /** @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(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, ...(cloudFront ? { cloudFront } : {}), ...(rum ? { rum } : {}), }; 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;