From 8e6e6f062d5ffa67dfc2ca4f646e89ebab7891eb Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:53:54 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A7=20fix:=20Emit=20the=20Configured-F?= =?UTF-8?q?ooter=20Answer=20With=20the=20HTML=20Shell=20(#15886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * πŸͺ§ fix: Emit the Configured-Footer Answer With the HTML Shell The chat surface lays out against whether the deployment configured a footer (customFooter, privacy policy, terms of service). The client answered that from localStorage β€” whatever the last resolved `/api/config` recorded β€” so the answer was a guess on a first-ever visit and could be stale for a deployment whose configuration changed. The backend already knows when it serves client/dist/index.html, so it says so there: `injectConfiguredFooterBootstrap` writes `hasConfiguredFooter` into `window.__LIBRECHAT_CONFIG__` ahead of the app's own scripts, once at boot from the base app config, and the client reads it on its first render instead of remembering the last answer. The `configured-footer` storage atom is gone. Client and server share one predicate (`hasConfiguredFooter` in librechat-data-provider) so the two cannot disagree, and the shell injection is generalized (`injectBootstrapConfig`), with the query-devtools flag now going through it. * 🧷 fix: Keep Bootstrap Values Literal and State the Shell's Contract `injectBootstrapConfig` passed its script as a replacement string, so a `$&`, `$1` or `$'` inside an injected value was expanded by `String.replace` instead of reaching the client. Both insertion points take a replacement function now, and the spec reads its values back off the global through an explicit result type rather than an unchecked dictionary. The shell answers from the deployment's own configuration, which a per-tenant, role or user override of `interface.privacyPolicy` or `termsOfService` can disagree with β€” `/api/config` resolves the caller's and the client prefers it. That contract was overstated in three comments ("a deployment-lifetime fact", "the same one the config will give"); they now say what the answer is and what resolves the rest. Two scenarios cover the disagreement end to end: a document served without the answer, and a policy link only this caller's config carries. --- api/server/experimental.js | 10 ++ api/server/index.js | 11 ++ api/server/index.spec.js | 17 ++ client/src/common/types.ts | 5 + client/src/components/Chat/ChatView.tsx | 12 +- client/src/components/Chat/Footer.tsx | 48 +++--- .../Chat/Subagents/SubagentThreadPanel.tsx | 6 +- client/src/components/Chat/footerMemory.ts | 14 -- .../scenarios/cold-load-clearance.spec.ts | 37 +++-- .../scenarios/footer-shell-answer.spec.ts | 148 ++++++++++++++++++ packages/api/src/html/bootstrap.spec.ts | 75 +++++++++ packages/api/src/html/bootstrap.ts | 42 +++++ packages/api/src/html/devtools.ts | 20 +-- packages/api/src/html/footer.spec.ts | 66 ++++++++ packages/api/src/html/footer.ts | 45 ++++++ packages/api/src/html/index.ts | 2 + packages/data-provider/src/footer.spec.ts | 26 +++ packages/data-provider/src/footer.ts | 31 ++++ packages/data-provider/src/index.ts | 1 + 19 files changed, 536 insertions(+), 80 deletions(-) delete mode 100644 client/src/components/Chat/footerMemory.ts create mode 100644 e2e/specs/mock/scenarios/footer-shell-answer.spec.ts create mode 100644 packages/api/src/html/bootstrap.spec.ts create mode 100644 packages/api/src/html/bootstrap.ts create mode 100644 packages/api/src/html/footer.spec.ts create mode 100644 packages/api/src/html/footer.ts create mode 100644 packages/data-provider/src/footer.spec.ts create mode 100644 packages/data-provider/src/footer.ts diff --git a/api/server/experimental.js b/api/server/experimental.js index ab4c6d0628..b77d5c33fd 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -37,6 +37,7 @@ const { initializeFileStorage, loadToolApprovalHooks, maybeInjectQueryDevtoolsBootstrap, + injectConfiguredFooterBootstrap, preAuthTenantMiddleware, requestContextMiddleware, configureServerTimeouts, @@ -551,6 +552,15 @@ if (cluster.isMaster) { } } + /* The composer lays out against whether a footer bar sits beneath it, and + `/api/config` answers that only after it has painted. One shell serves + every request, before there is a caller whose overrides could be resolved, + so the answer is the deployment's base configuration, like index.js. */ + indexHTML = injectConfiguredFooterBootstrap(indexHTML, { + customFooter: process.env.CUSTOM_FOOTER, + interfaceConfig: baseAppConfig?.interfaceConfig, + }); + const cspPolicy = createCspPolicy(); const shellCache = shellCacheHeaders(cspPolicy != null); diff --git a/api/server/index.js b/api/server/index.js index a7ae9a6fa6..b8c0ec5b51 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -43,6 +43,7 @@ const { setPluginHookSource, loadToolApprovalHooks, maybeInjectQueryDevtoolsBootstrap, + injectConfiguredFooterBootstrap, preAuthTenantMiddleware, requestContextMiddleware, registerShutdownTask, @@ -284,6 +285,16 @@ const startServer = async () => { } } + /* The composer lays out against whether a footer bar sits beneath it, and + `/api/config` answers that only after it has painted. One shell serves every + request, before there is a caller whose overrides could be resolved, so the + answer is the deployment's base configuration; `/api/config` resolves the + caller's and the client prefers it. */ + indexHTML = injectConfiguredFooterBootstrap(indexHTML, { + customFooter: process.env.CUSTOM_FOOTER, + interfaceConfig: appConfig?.interfaceConfig, + }); + const cspPolicy = createCspPolicy(); const shellCache = shellCacheHeaders(cspPolicy != null); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index b113ab1b97..e1b565dcc3 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -280,6 +280,9 @@ describe('Server Configuration', () => { mongoServer = await MongoMemoryServer.create(); process.env.MONGO_URI = mongoServer.getUri(); process.env.PORT = '0'; // Use a random available port + /* This deployment configures a footer, so the shell it serves has to say so + before any `/api/config` request: the composer lays out against it. */ + process.env.CUSTOM_FOOTER = 'Operator policy footer'; /* index.js listens at module scope and exports only the app, so capture the server to close it. */ const listenSpy = jest.spyOn(express.application, 'listen'); app = require('~/server'); @@ -294,6 +297,7 @@ describe('Server Configuration', () => { await promisify(server.close).call(server); await mongoServer.stop(); await mongoose.disconnect(); + delete process.env.CUSTOM_FOOTER; }); it('should return OK for /health', async () => { @@ -391,6 +395,19 @@ describe('Server Configuration', () => { expect(directIndexResponse.text).toContain('"enableQueryDevtools":true'); }); + it('serves the configured-footer answer with the shell', async () => { + const [fallbackResponse, indexResponse] = await Promise.all([ + request(app).get('/this/does/not/exist'), + request(app).get('/index.html'), + ]); + + for (const response of [fallbackResponse, indexResponse]) { + expect(response.status).toBe(200); + expect(response.text).toContain('window.__LIBRECHAT_CONFIG__'); + expect(response.text).toContain('"hasConfiguredFooter":true'); + } + }); + it('should return 500 for unknown errors via ErrorController', async () => { // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated diff --git a/client/src/common/types.ts b/client/src/common/types.ts index ed041c425b..2c39712cbd 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -676,8 +676,13 @@ export type TThread = { id: string; createdAt: string }; declare global { interface Window { google_tag_manager?: unknown; + /** Answers the server emits with the document, ahead of the app's own + * scripts, for questions the first render must not guess at. */ __LIBRECHAT_CONFIG__?: { enableQueryDevtools?: boolean; + /** Whether this deployment configured footer content of its own, so the + * composer reserves the footer bar's band on its first frame. */ + hasConfiguredFooter?: boolean; }; } } diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 24186fa708..8e127931f5 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -59,7 +59,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje /** A conversation carries a footer only for configured content, and the * composer's clearance has to account for the bar when it does β€” including - * while the config is still in flight, so a cold load does not jump. */ + * before the config answers, so a cold load does not jump. */ const configuredFooter = useConfiguredFooter(); const methods = useForm({ @@ -114,10 +114,10 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje (conversationId === Constants.NEW_CONVO || !conversationId); /** A footer bar renders beneath the composer on the welcome screen always, and - * in a conversation when the deployment configured one. `present` already - * carries the remembered answer while the config is in flight, so this is the - * same value before and after it resolves. */ - const footerBelow = isLandingPage || configuredFooter.present; + * in a conversation when the deployment configured one. The shell already + * carried that answer, so this is the same value before and after the config + * resolves. */ + const footerBelow = isLandingPage || configuredFooter; const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null; const isProjectLandingPage = isLandingPage && project != null; @@ -214,7 +214,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje {/* The generic disclaimer is the welcome screen's; a deployment's own footer, privacy policy and terms stay with the conversation that always showed them. */} - {!isLandingPage && configuredFooter.present &&