⚖️ refactor: Split Config Route into Unauthenticated and Authenticated Paths (#12490)

* refactor: split /api/config into unauthenticated and authenticated response paths

- Replace preAuthTenantMiddleware with optionalJwtAuth on the /api/config
  route so the handler can detect whether the request is authenticated
- When unauthenticated: call getAppConfig({ baseOnly: true }) for zero DB
  queries, return only login-relevant fields (social logins, turnstile,
  privacy policy / terms of service from interface config)
- When authenticated: call getAppConfig({ role, userId, tenantId }) to
  resolve per-user DB overrides (USER + ROLE + GROUP + PUBLIC principals),
  return full payload including modelSpecs, balance, webSearch, etc.
- Extract buildSharedPayload() and addWebSearchConfig() helpers to avoid
  duplication between the two code paths
- Fixes per-user balance overrides not appearing in the frontend because
  userId was never passed to getAppConfig (follow-up to #12474)

* test: rewrite config route tests for unauthenticated vs authenticated paths

- Replace the previously-skipped supertest tests with proper mocked tests
- Cover unauthenticated path: baseOnly config call, minimal payload,
  interface subset (privacyPolicy/termsOfService only), exclusion of
  authenticated-only fields
- Cover authenticated path: getAppConfig called with userId, full payload
  including modelSpecs/balance/webSearch, per-user balance override merging

* fix: address review findings — restore multi-tenant support, improve tests

- Chain preAuthTenantMiddleware back before optionalJwtAuth on /api/config
  so unauthenticated requests in multi-tenant deployments still get
  tenant-scoped config via X-Tenant-Id header (Finding #1)
- Use getAppConfig({ tenantId }) instead of getAppConfig({ baseOnly: true })
  when a tenant context is present; fall back to baseOnly for single-tenant
- Fix @type annotation: unauthenticated payload is Partial<TStartupConfig>
- Refactor addWebSearchConfig into pure buildWebSearchConfig that returns a
  value instead of mutating the payload argument
- Hoist isBirthday() to module level
- Remove inline narration comments
- Assert tenantId propagation in tests, including getTenantId fallback and
  user.tenantId preference
- Add error-path tests for both unauthenticated and authenticated branches
- Expand afterEach env var cleanup for proper test isolation

* test: fix mock isolation and add tenant-scoped response test

- Replace jest.clearAllMocks() with jest.resetAllMocks() so
  mockReturnValue implementations don't leak between tests
- Add test verifying tenant-scoped socialLogins and turnstile are
  correctly mapped in the unauthenticated response

* fix: add optionalJwtAuth to /api/config in experimental.js

Without this middleware, req.user is never populated in the experimental
cluster entrypoint, so authenticated users always receive the minimal
unauthenticated config payload.
This commit is contained in:
Danny Avila 2026-03-31 19:22:51 -04:00 committed by GitHub
parent 7181174c3b
commit 2e706ebcb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 390 additions and 165 deletions

View file

@ -19,120 +19,157 @@ const publicSharedLinksEnabled =
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
router.get('/', async function (req, res) {
const isBirthday = () => {
const today = new Date();
return today.getMonth() === 1 && today.getDate() === 11;
};
function isBirthday() {
const today = new Date();
return today.getMonth() === 1 && today.getDate() === 11;
}
function buildSharedPayload() {
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&
!!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,
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,
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
openidReuseTokens,
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
if (ldap) {
payload.ldap = ldap;
}
if (typeof process.env.CUSTOM_FOOTER === 'string') {
payload.customFooter = process.env.CUSTOM_FOOTER;
}
return payload;
}
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 }),
};
}
router.get('/', async function (req, res) {
try {
const sharedPayload = buildSharedPayload();
if (!req.user) {
const tenantId = getTenantId();
const baseConfig = await getAppConfig(tenantId ? { tenantId } : { baseOnly: true });
/** @type {Partial<TStartupConfig>} */
const payload = {
...sharedPayload,
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
};
const interfaceConfig = baseConfig?.interfaceConfig;
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService) {
payload.interface = {};
if (interfaceConfig.privacyPolicy) {
payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
}
if (interfaceConfig.termsOfService) {
payload.interface.termsOfService = interfaceConfig.termsOfService;
}
}
return res.status(200).send(payload);
}
const appConfig = await getAppConfig({
role: req.user?.role,
tenantId: req.user?.tenantId || getTenantId(),
role: req.user.role,
userId: req.user.id,
tenantId: req.user.tenantId || getTenantId(),
});
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
!!process.env.OPENID_CLIENT_SECRET &&
!!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 balanceConfig = getBalanceConfig(appConfig);
/** @type {TStartupConfig} */
const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat',
...sharedPayload,
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
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,
showBirthdayIcon:
isBirthday() ||
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: appConfig?.interfaceConfig,
turnstile: appConfig?.turnstileConfig,
modelSpecs: appConfig?.modelSpecs,
balance: balanceConfig,
sharedLinksEnabled,
publicSharedLinksEnabled,
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
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,
openidReuseTokens,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
const webSearchConfig = appConfig?.webSearch;
if (
webSearchConfig != null &&
(webSearchConfig.searchProvider ||
webSearchConfig.scraperProvider ||
webSearchConfig.rerankerType)
) {
payload.webSearch = {};
}
if (webSearchConfig?.searchProvider) {
payload.webSearch.searchProvider = webSearchConfig.searchProvider;
}
if (webSearchConfig?.scraperProvider) {
payload.webSearch.scraperProvider = webSearchConfig.scraperProvider;
}
if (webSearchConfig?.rerankerType) {
payload.webSearch.rerankerType = webSearchConfig.rerankerType;
}
if (ldap) {
payload.ldap = ldap;
}
if (typeof process.env.CUSTOM_FOOTER === 'string') {
payload.customFooter = process.env.CUSTOM_FOOTER;
const webSearch = buildWebSearchConfig(appConfig);
if (webSearch) {
payload.webSearch = webSearch;
}
return res.status(200).send(payload);