diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index d7fbd04446..606b4ae8a1 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -101,6 +101,9 @@ afterEach(() => { delete process.env.SAML_CERT; delete process.env.SAML_SESSION_SECRET; delete process.env.ALLOW_ACCOUNT_DELETION; + delete process.env.ANALYTICS_GTM_ID; + delete process.env.CUSTOM_FOOTER; + delete process.env.HELP_AND_FAQ_URL; }); describe('GET /api/config', () => { @@ -156,9 +159,48 @@ describe('GET /api/config', () => { expect(response.body).not.toHaveProperty('bundlerURL'); expect(response.body).not.toHaveProperty('staticBundlerURL'); expect(response.body).not.toHaveProperty('sharePointFilePickerEnabled'); + expect(response.body).not.toHaveProperty('sharePointBaseUrl'); + expect(response.body).not.toHaveProperty('sharePointPickerGraphScope'); + expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope'); expect(response.body).not.toHaveProperty('conversationImportMaxFileSize'); }); + it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'internal footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('showBirthdayIcon'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('sharedLinksEnabled'); + expect(response.body).not.toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).not.toHaveProperty('analyticsGtmId'); + expect(response.body).not.toHaveProperty('openidReuseTokens'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + expect(response.body).not.toHaveProperty('customFooter'); + }); + + it('should include public share footer fields when share context is requested', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'public footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config?context=share'); + + expect(response.statusCode).toBe(200); + expect(response.body.analyticsGtmId).toBe('GTM-XYZ'); + expect(response.body.customFooter).toBe('public footer text'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + }); + it('should include socialLogins and turnstile from base config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(null); @@ -206,7 +248,7 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('serverDomain'); }); - it('should advertise CloudFront cookie refresh only when signed-cookie mode is active', async () => { + it('should omit CloudFront cookie refresh from unauthenticated response (#12688)', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); mockGetCloudFrontConfig.mockReturnValue({ domain: 'https://cdn.example.com', @@ -219,69 +261,9 @@ describe('GET /api/config', () => { const response = await request(app).get('/api/config'); - expect(response.body.cloudFront).toEqual({ - cookieRefresh: { - endpoint: '/api/auth/cloudfront/refresh', - domain: 'https://cdn.example.com', - }, - }); - }); - - it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'url', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - expect(response.body).not.toHaveProperty('cloudFront'); }); - it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'cookies', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body).not.toHaveProperty('cloudFront'); - }); - - it('should default allowAccountDeletion to true when env var is unset', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - - it('should set allowAccountDeletion to false when ALLOW_ACCOUNT_DELETION=false', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'false'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(false); - }); - - it('should set allowAccountDeletion to true when ALLOW_ACCOUNT_DELETION=true', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'true'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - it('should return 500 when getAppConfig throws', async () => { mockGetAppConfig.mockRejectedValue(new Error('Config service failure')); const app = createApp(null); @@ -395,6 +377,70 @@ describe('GET /api/config', () => { expect(response.body.conversationImportMaxFileSize).toBe(5000000); }); + it('should include post-login informational fields', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'authenticated footer text'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).toHaveProperty('helpAndFaqURL'); + expect(response.body).toHaveProperty('sharedLinksEnabled'); + expect(response.body).toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).toHaveProperty('showBirthdayIcon'); + expect(response.body).toHaveProperty('openidReuseTokens'); + expect(response.body.analyticsGtmId).toBe('GTM-XYZ'); + expect(response.body.customFooter).toBe('authenticated footer text'); + }); + + it('should advertise CloudFront cookie refresh when signed-cookie mode is active', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-private-key', + keyPairId: 'K123ABC', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.cloudFront).toEqual({ + cookieRefresh: { + endpoint: '/api/auth/cloudfront/refresh', + domain: 'https://cdn.example.com', + }, + }); + }); + + it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'url', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + + it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + it('should merge per-user balance override into config', async () => { mockGetAppConfig.mockResolvedValue({ ...baseAppConfig, diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 7038ac2f71..c9ffe67788 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -39,7 +39,14 @@ function isBirthday() { return today.getMonth() === 1 && today.getDate() === 11; } -function buildSharedPayload() { +/** + * 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()) && @@ -83,19 +90,6 @@ function buildSharedPayload() { !!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, - /** 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), }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); @@ -107,6 +101,20 @@ function buildSharedPayload() { payload.ldap = ldap; } + return payload; +} + +/** + * Public share fields rendered by `client/src/components/Share/ShareView.tsx`. + * They remain off the default anonymous config used by login screens, and are + * exposed to anonymous callers only when the client asks for share context. + */ +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; } @@ -114,6 +122,32 @@ function buildSharedPayload() { 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; @@ -168,8 +202,8 @@ function buildCloudFrontStartupConfig() { router.get('/', async function (req, res) { try { - const sharedPayload = buildSharedPayload(); - const cloudFront = buildCloudFrontStartupConfig(); + const preLoginPayload = buildPreLoginPayload(); + const publicSharePayload = buildPublicSharePayload(); const rum = getRumConfig(); if (!req.user) { @@ -178,10 +212,10 @@ router.get('/', async function (req, res) { /** @type {Partial} */ const payload = { - ...sharedPayload, + ...preLoginPayload, + ...(req.query.context === 'share' ? publicSharePayload : {}), socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins, turnstile: baseConfig?.turnstileConfig, - ...(cloudFront ? { cloudFront } : {}), ...(rum ? { rum } : {}), }; @@ -215,10 +249,13 @@ router.get('/', async function (req, res) { }); const balanceConfig = getBalanceConfig(appConfig); + const cloudFront = buildCloudFrontStartupConfig(); /** @type {TStartupConfig} */ const payload = { - ...sharedPayload, + ...preLoginPayload, + ...publicSharePayload, + ...buildPostLoginPayload(), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, turnstile: appConfig?.turnstileConfig, diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 541647a8d0..b841a98819 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -2,11 +2,19 @@ import React, { useEffect, memo } from 'react'; import TagManager from 'react-gtm-module'; import ReactMarkdown from 'react-markdown'; import { Constants } from 'librechat-data-provider'; +import type { TStartupConfig } from 'librechat-data-provider'; import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; -function Footer({ className }: { className?: string }) { - const { data: config } = useGetStartupConfig(); +type FooterProps = { + className?: string; + startupConfig?: TStartupConfig | null; +}; + +function Footer({ className, startupConfig }: FooterProps) { + const shouldFetchConfig = startupConfig === undefined; + const { data: fetchedConfig } = useGetStartupConfig({ enabled: shouldFetchConfig }); + const config = shouldFetchConfig ? fetchedConfig : startupConfig; const localize = useLocalize(); const privacyPolicy = config?.interface?.privacyPolicy; diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 00a0d36398..6424546a23 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -29,7 +29,7 @@ import store from '~/store'; function SharedView() { const localize = useLocalize(); - const { data: config } = useGetStartupConfig(); + const { data: config } = useGetStartupConfig(undefined, { context: 'share' }); const { theme, setTheme } = useContext(ThemeContext); const { shareId } = useParams(); const { data, isLoading } = useGetSharedMessages(shareId ?? ''); @@ -124,7 +124,10 @@ function SharedView() { const footer = (
-
+
); diff --git a/client/src/data-provider/Endpoints/queries.ts b/client/src/data-provider/Endpoints/queries.ts index 3ce3d35110..17527c6dc1 100644 --- a/client/src/data-provider/Endpoints/queries.ts +++ b/client/src/data-provider/Endpoints/queries.ts @@ -28,17 +28,18 @@ export const useGetEndpointsQuery = ( * (chat page) configs are cached independently, preventing stale * unauthenticated config from persisting after login. */ -export const startupConfigKey = (isAuthenticated: boolean) => - [QueryKeys.startupConfig, isAuthenticated] as const; +export const startupConfigKey = (isAuthenticated: boolean, context?: t.StartupConfigContext) => + [QueryKeys.startupConfig, isAuthenticated, context ?? 'default'] as const; export const useGetStartupConfig = ( config?: UseQueryOptions, + options?: { context?: t.StartupConfigContext }, ): QueryObserverResult => { const queriesEnabled = useRecoilValue(store.queriesEnabled); const user = useRecoilValue(store.user); return useQuery( - startupConfigKey(!!user), - () => dataService.getStartupConfig(), + startupConfigKey(!!user, options?.context), + () => dataService.getStartupConfig({ context: options?.context }), { staleTime: Infinity, refetchOnWindowFocus: false, diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 0a50f678d4..16516888ac 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -1,3 +1,4 @@ +import type { StartupConfigContext } from './config'; import type { AssistantsEndpoint } from './schemas'; import * as q from './types/queries'; import { ResourceType } from './accessPermissions'; @@ -212,7 +213,8 @@ export const mcpOAuthBind = (serverName: string) => `${BASE_URL}/api/mcp/${serve export const actionOAuthBind = (actionId: string) => `${BASE_URL}/api/actions/${actionId}/oauth/bind`; -export const config = () => `${BASE_URL}/api/config`; +export const config = (context?: StartupConfigContext) => + `${BASE_URL}/api/config${buildQuery({ context })}`; export const prompts = () => `${BASE_URL}/api/prompts`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 7e8f8b259f..a35542e714 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1081,6 +1081,8 @@ export type TRumConfig = { environment?: string; }; +export type StartupConfigContext = 'share'; + export type TStartupConfig = { appTitle: string; socialLogins?: string[]; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 4704a5bbe0..732d5041fe 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -228,12 +228,18 @@ export function cancelMCPOAuth(serverName: string): Promise; } > => { - return request.get(endpoints.config()); + return request.get(endpoints.config(options?.context)); }; export const getAIEndpoints = (): Promise => {