From 7a5601a7f6efa14bc04809908cd17ad1e3173d74 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Sat, 16 May 2026 00:23:00 +0500 Subject: [PATCH 01/12] 46741 feat(frontend): migrate environment variables to runtime config --- docker-compose.src.yml | 5 +- docker-compose.yml | 5 +- frontend/Dockerfile | 5 ++ frontend/config/common.json | 3 +- frontend/src/public/constants/enviroment.ts | 72 ++++++++++++--------- frontend/src/public/utils/getConfig.ts | 33 ++++++++++ frontend/src/public/utils/initSentry.ts | 5 +- frontend/webpack.config.js | 9 +-- 8 files changed, 90 insertions(+), 47 deletions(-) diff --git a/docker-compose.src.yml b/docker-compose.src.yml index 8a56efeba..3bd90c3a6 100755 --- a/docker-compose.src.yml +++ b/docker-compose.src.yml @@ -396,10 +396,7 @@ services: FIREBASE_APP_ID: ${FIREBASE_APP_ID:-} FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID:-} RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} - command: > - sh -c " - npm run build-client:prod && - pm2-runtime start pm2.json" + command: pm2-runtime start pm2.json expose: - 8000 networks: diff --git a/docker-compose.yml b/docker-compose.yml index ea914dc76..e2931ce12 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -399,10 +399,7 @@ services: FIREBASE_APP_ID: ${FIREBASE_APP_ID:-} FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID:-} RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} - command: > - sh -c " - npm run build-client:prod && - pm2-runtime start pm2.json" + command: pm2-runtime start pm2.json expose: - 8000 networks: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f30ed65d5..ab994e74a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -21,3 +21,8 @@ ADD package.json /pneumatic_frontend/package.json RUN npm ci --legacy-peer-deps ADD . /pneumatic_frontend/ + +# Build webpack bundle at image build time (env-independent since runtime config refactor) +RUN NODE_ENV=production npx webpack + +CMD ["pm2-runtime", "start", "pm2.json"] diff --git a/frontend/config/common.json b/frontend/config/common.json index 968b3a48a..04c1cb2a1 100644 --- a/frontend/config/common.json +++ b/frontend/config/common.json @@ -172,6 +172,7 @@ "mainPage", "formSubdomain", "recaptchaSecret", - "firebase" + "firebase", + "featureFlags" ] } diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 5fdeb00bb..86d501e42 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -1,42 +1,56 @@ -export const envLanguageCode: string | undefined = process.env.LANGUAGE_CODE; -export const envBackendURL: string | undefined = process.env.BACKEND_URL; -export const envSentry: string | undefined = process.env.SENTRY_DSN; -export const envWssURL: string | undefined = process.env.WSS_URL; -export const envBackendPrivateIP: string | undefined = process.env.BACKEND_PRIVATE_IP; +const getEnvVar = (key: string): string | undefined => { + if (typeof window !== 'undefined') { + const pneumaticConfig = (window as any).__pneumaticConfig; + if (pneumaticConfig?.config?.featureFlags && pneumaticConfig.config.featureFlags[key] !== undefined) { + return pneumaticConfig.config.featureFlags[key]; + } + } + if (typeof process !== 'undefined' && process.env) { + return process.env[key]; + } + return undefined; +}; + +export const envLanguageCode: string | undefined = getEnvVar('LANGUAGE_CODE'); +export const envBackendURL: string | undefined = getEnvVar('BACKEND_URL'); +export const envSentry: string | undefined = getEnvVar('SENTRY_DSN'); +export const envWssURL: string | undefined = getEnvVar('WSS_URL'); +export const envBackendPrivateIP: string | undefined = getEnvVar('BACKEND_PRIVATE_IP'); export const envDevMode: boolean = process.env.NODE_ENV === 'development'; -export const isEnvCaptcha: boolean = process.env.CAPTCHA !== 'no'; -export const isEnvGoogleAuth: boolean = process.env.GOOGLE_AUTH !== 'no'; -export const isEnvMsAuth: boolean = process.env.MS_AUTH !== 'no'; -export const isEnvSSOAuth: boolean = process.env.SSO_AUTH !== 'no'; -export const isEnvSignup: boolean = process.env.SIGNUP !== 'no'; -export const isEnvBilling: boolean = process.env.BILLING !== 'no'; -export const isEnvAi: boolean = process.env.AI !== 'no'; -export const isEnvPush: boolean = process.env.PUSH !== 'no'; -export const isEnvStorage: boolean = process.env.STORAGE !== 'no'; -export const isEnvAnalytics: boolean = process.env.ANALYTICS !== 'no'; -export const envSSOProvider: string | undefined = process.env.SSO_PROVIDER; +export const isEnvCaptcha: boolean = getEnvVar('CAPTCHA') !== 'no'; +export const isEnvGoogleAuth: boolean = getEnvVar('GOOGLE_AUTH') !== 'no'; +export const isEnvMsAuth: boolean = getEnvVar('MS_AUTH') !== 'no'; +export const isEnvSSOAuth: boolean = getEnvVar('SSO_AUTH') !== 'no'; +export const isEnvSignup: boolean = getEnvVar('SIGNUP') !== 'no'; +export const isEnvBilling: boolean = getEnvVar('BILLING') !== 'no'; +export const isEnvAi: boolean = getEnvVar('AI') !== 'no'; +export const isEnvPush: boolean = getEnvVar('PUSH') !== 'no'; +export const isEnvStorage: boolean = getEnvVar('STORAGE') !== 'no'; +export const isEnvAnalytics: boolean = getEnvVar('ANALYTICS') !== 'no'; +export const envSSOProvider: string | undefined = getEnvVar('SSO_PROVIDER'); // New ENV -export const envHost: string | undefined = process.env.HOST; -export const envAnalyticsId: string | undefined = process.env.ANALYTICS_ID; -export const envRecaptchaSecret: string | undefined = process.env.RECAPTCHA_SECRET; +export const envHost: string | undefined = getEnvVar('HOST'); +export const envAnalyticsId: string | undefined = getEnvVar('ANALYTICS_ID'); +export const envRecaptchaSecret: string | undefined = getEnvVar('RECAPTCHA_SECRET'); -export const envGoogleClientId: string | undefined = process.env.GOOGLE_CLIENT_ID; -export const envGoogleClientSecret: string | undefined = process.env.GOOGLE_CLIENT_SECRET; +export const envGoogleClientId: string | undefined = getEnvVar('GOOGLE_CLIENT_ID'); +export const envGoogleClientSecret: string | undefined = getEnvVar('GOOGLE_CLIENT_SECRET'); +export const envSentryRelease: string | undefined = getEnvVar('SENTRY_RELEASE'); export const envFirebase: any = { - vapidKey: process.env.FIREBASE_VAPID_KEY, + vapidKey: getEnvVar('FIREBASE_VAPID_KEY'), config: { - apiKey: process.env.FIREBASE_API_KEY, - authDomain: process.env.FIREBASE_AUTH_DOMAIN, - projectId: process.env.FIREBASE_PROJECT_ID, - storageBucket: process.env.FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.FIREBASE_APP_ID, - measurementId: process.env.FIREBASE_MEASUREMENT_ID + apiKey: getEnvVar('FIREBASE_API_KEY'), + authDomain: getEnvVar('FIREBASE_AUTH_DOMAIN'), + projectId: getEnvVar('FIREBASE_PROJECT_ID'), + storageBucket: getEnvVar('FIREBASE_STORAGE_BUCKET'), + messagingSenderId: getEnvVar('FIREBASE_MESSAGING_SENDER_ID'), + appId: getEnvVar('FIREBASE_APP_ID'), + measurementId: getEnvVar('FIREBASE_MEASUREMENT_ID') } }; diff --git a/frontend/src/public/utils/getConfig.ts b/frontend/src/public/utils/getConfig.ts index 312768c72..66b00c19b 100644 --- a/frontend/src/public/utils/getConfig.ts +++ b/frontend/src/public/utils/getConfig.ts @@ -35,6 +35,7 @@ export interface IBrowserConfig { measurementId: string; }; }; + featureFlags: Record; } interface IConfig { @@ -117,6 +118,38 @@ export function getConfig(): TConfig { "measurementId": FIREBASE_MEASUREMENT_ID } }, + "featureFlags": { + "CAPTCHA": process.env.CAPTCHA, + "GOOGLE_AUTH": process.env.GOOGLE_AUTH, + "MS_AUTH": process.env.MS_AUTH, + "SSO_AUTH": process.env.SSO_AUTH, + "SIGNUP": process.env.SIGNUP, + "BILLING": process.env.BILLING, + "AI": process.env.AI, + "PUSH": process.env.PUSH, + "STORAGE": process.env.STORAGE, + "ANALYTICS": process.env.ANALYTICS, + "SSO_PROVIDER": process.env.SSO_PROVIDER, + "LANGUAGE_CODE": process.env.LANGUAGE_CODE, + "BACKEND_URL": process.env.BACKEND_URL, + "SENTRY_DSN": process.env.SENTRY_DSN, + "WSS_URL": process.env.WSS_URL, + "BACKEND_PRIVATE_IP": process.env.BACKEND_PRIVATE_IP, + "HOST": process.env.HOST, + "ANALYTICS_ID": process.env.ANALYTICS_ID, + "RECAPTCHA_SECRET": process.env.RECAPTCHA_SECRET, + "GOOGLE_CLIENT_ID": process.env.GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_SECRET": process.env.GOOGLE_CLIENT_SECRET, + "FIREBASE_VAPID_KEY": process.env.FIREBASE_VAPID_KEY, + "FIREBASE_API_KEY": process.env.FIREBASE_API_KEY, + "FIREBASE_AUTH_DOMAIN": process.env.FIREBASE_AUTH_DOMAIN, + "FIREBASE_PROJECT_ID": process.env.FIREBASE_PROJECT_ID, + "FIREBASE_STORAGE_BUCKET": process.env.FIREBASE_STORAGE_BUCKET, + "FIREBASE_MESSAGING_SENDER_ID": process.env.FIREBASE_MESSAGING_SENDER_ID, + "FIREBASE_APP_ID": process.env.FIREBASE_APP_ID, + "FIREBASE_MEASUREMENT_ID": process.env.FIREBASE_MEASUREMENT_ID, + "SENTRY_RELEASE": process.env.SENTRY_RELEASE + } }); } diff --git a/frontend/src/public/utils/initSentry.ts b/frontend/src/public/utils/initSentry.ts index fff2f3944..a7bbb789c 100644 --- a/frontend/src/public/utils/initSentry.ts +++ b/frontend/src/public/utils/initSentry.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react'; import { setSentryCapture } from './sentryCapture'; import { DEV_SENTRY_DSN, PROD_SENTRY_DSN } from '../constants/defaultValues'; import { TEnvironment } from './getConfig'; +import { envSentryRelease } from '../constants/enviroment'; export type TSentryApp = 'main' | 'forms'; @@ -20,8 +21,8 @@ export const initSentry = ( const dsn = sentryDsnMap[env]; if (!dsn) return; - const release = typeof process.env.SENTRY_RELEASE === 'string' - ? process.env.SENTRY_RELEASE + const release = typeof envSentryRelease === 'string' + ? envSentryRelease : undefined; Sentry.init({ diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 8108bb151..a98ea9957 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -1,20 +1,14 @@ const webpack = require('webpack'); const path = require('path'); -const dotenv = require('dotenv'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const env = dotenv.config().parsed; const { NODE_ENV = 'development', MCS_RUN_ENV = 'local' } = process.env; const devMode = NODE_ENV !== 'production'; const fontsDir = path.resolve(__dirname, './src/public/assets'); -const envKeys = Object.keys(Object.assign(env, process.env)).reduce((prev, next) => { - prev[next] = JSON.stringify(env[next]); - return prev; -}, {}); const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; const sentryRelease = process.env.SENTRY_RELEASE; @@ -97,7 +91,8 @@ module.exports = { }, plugins: [ new webpack.DefinePlugin({ - 'process.env': envKeys, + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'process.env.MCS_RUN_ENV': JSON.stringify(MCS_RUN_ENV), }), new webpack.HotModuleReplacementPlugin(), new MiniCssExtractPlugin({ From 32ee3bf81ac4a67779d203a7552beaf73e3e2131 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 04:51:47 +0500 Subject: [PATCH 02/12] 46741 fix(frontend): remove secrets from browser config and fix Firebase env var --- frontend/src/public/constants/enviroment.ts | 4 +--- frontend/src/public/utils/getConfig.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 86d501e42..12f2df2ca 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -15,7 +15,6 @@ export const envLanguageCode: string | undefined = getEnvVar('LANGUAGE_CODE export const envBackendURL: string | undefined = getEnvVar('BACKEND_URL'); export const envSentry: string | undefined = getEnvVar('SENTRY_DSN'); export const envWssURL: string | undefined = getEnvVar('WSS_URL'); -export const envBackendPrivateIP: string | undefined = getEnvVar('BACKEND_PRIVATE_IP'); export const envDevMode: boolean = process.env.NODE_ENV === 'development'; export const isEnvCaptcha: boolean = getEnvVar('CAPTCHA') !== 'no'; @@ -39,7 +38,6 @@ export const envAnalyticsId: string | undefined = getEnvVar('ANALYTICS_ID'); export const envRecaptchaSecret: string | undefined = getEnvVar('RECAPTCHA_SECRET'); export const envGoogleClientId: string | undefined = getEnvVar('GOOGLE_CLIENT_ID'); -export const envGoogleClientSecret: string | undefined = getEnvVar('GOOGLE_CLIENT_SECRET'); export const envSentryRelease: string | undefined = getEnvVar('SENTRY_RELEASE'); export const envFirebase: any = { @@ -49,7 +47,7 @@ export const envFirebase: any = { authDomain: getEnvVar('FIREBASE_AUTH_DOMAIN'), projectId: getEnvVar('FIREBASE_PROJECT_ID'), storageBucket: getEnvVar('FIREBASE_STORAGE_BUCKET'), - messagingSenderId: getEnvVar('FIREBASE_MESSAGING_SENDER_ID'), + messagingSenderId: getEnvVar('FIREBASE_SENDER_ID'), appId: getEnvVar('FIREBASE_APP_ID'), measurementId: getEnvVar('FIREBASE_MEASUREMENT_ID') } diff --git a/frontend/src/public/utils/getConfig.ts b/frontend/src/public/utils/getConfig.ts index 66b00c19b..fa291ca81 100644 --- a/frontend/src/public/utils/getConfig.ts +++ b/frontend/src/public/utils/getConfig.ts @@ -134,18 +134,16 @@ export function getConfig(): TConfig { "BACKEND_URL": process.env.BACKEND_URL, "SENTRY_DSN": process.env.SENTRY_DSN, "WSS_URL": process.env.WSS_URL, - "BACKEND_PRIVATE_IP": process.env.BACKEND_PRIVATE_IP, "HOST": process.env.HOST, "ANALYTICS_ID": process.env.ANALYTICS_ID, "RECAPTCHA_SECRET": process.env.RECAPTCHA_SECRET, "GOOGLE_CLIENT_ID": process.env.GOOGLE_CLIENT_ID, - "GOOGLE_CLIENT_SECRET": process.env.GOOGLE_CLIENT_SECRET, "FIREBASE_VAPID_KEY": process.env.FIREBASE_VAPID_KEY, "FIREBASE_API_KEY": process.env.FIREBASE_API_KEY, "FIREBASE_AUTH_DOMAIN": process.env.FIREBASE_AUTH_DOMAIN, "FIREBASE_PROJECT_ID": process.env.FIREBASE_PROJECT_ID, "FIREBASE_STORAGE_BUCKET": process.env.FIREBASE_STORAGE_BUCKET, - "FIREBASE_MESSAGING_SENDER_ID": process.env.FIREBASE_MESSAGING_SENDER_ID, + "FIREBASE_SENDER_ID": process.env.FIREBASE_SENDER_ID, "FIREBASE_APP_ID": process.env.FIREBASE_APP_ID, "FIREBASE_MEASUREMENT_ID": process.env.FIREBASE_MEASUREMENT_ID, "SENTRY_RELEASE": process.env.SENTRY_RELEASE From 5e83ad004041c0aaa80cbc2ac770e817661a7dca Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 17:30:00 +0500 Subject: [PATCH 03/12] 46741 fix(docker): add COPY instruction to backend Dockerfile for Railway deployment --- backend/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index eb5c3c524..08e8d9f8f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,3 +25,5 @@ COPY ./pyproject.toml ./poetry.lock /tmp/ WORKDIR /pneumatic_backend RUN poetry config virtualenvs.create false && \ poetry install --without dev --no-root --no-interaction --directory /tmp + +COPY . . From 9fd988fd0db4aaeffa7943a0f39d97647985f67c Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 18:13:48 +0500 Subject: [PATCH 04/12] 46741 refactor(frontend): remove dead MCS_RUN_ENV from webpack config --- frontend/webpack.config.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index a98ea9957..4d127e330 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -5,7 +5,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const { NODE_ENV = 'development', MCS_RUN_ENV = 'local' } = process.env; +const { NODE_ENV = 'development' } = process.env; const devMode = NODE_ENV !== 'production'; const fontsDir = path.resolve(__dirname, './src/public/assets'); @@ -92,7 +92,6 @@ module.exports = { plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), - 'process.env.MCS_RUN_ENV': JSON.stringify(MCS_RUN_ENV), }), new webpack.HotModuleReplacementPlugin(), new MiniCssExtractPlugin({ @@ -102,7 +101,7 @@ module.exports = { chunks: ['main'], filename: 'main.ejs', template: '!!raw-loader!./src/public/index.ejs', - mcsRunEnv: MCS_RUN_ENV, + removeComments: true, favicon: './src/public/assets/favicon.png', }), @@ -110,7 +109,7 @@ module.exports = { chunks: ['forms'], filename: 'forms.ejs', template: '!!raw-loader!./src/public/forms.ejs', - mcsRunEnv: MCS_RUN_ENV, + removeComments: true, favicon: './src/public/assets/favicon.png', }), From 6774e895bcc73965d68d6a8fca3d6879ac8c04b0 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 19:43:16 +0500 Subject: [PATCH 05/12] 46741 fix(frontend): use correct RECAPTCHA_SITE_KEY env var in featureFlags --- frontend/src/public/constants/enviroment.ts | 3 ++- frontend/src/public/utils/getConfig.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 12f2df2ca..3c8c94234 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -1,5 +1,6 @@ const getEnvVar = (key: string): string | undefined => { if (typeof window !== 'undefined') { + // eslint-disable-next-line no-underscore-dangle const pneumaticConfig = (window as any).__pneumaticConfig; if (pneumaticConfig?.config?.featureFlags && pneumaticConfig.config.featureFlags[key] !== undefined) { return pneumaticConfig.config.featureFlags[key]; @@ -35,7 +36,7 @@ export const envSSOProvider: string | undefined = getEnvVar('SSO_PROVIDER'); export const envHost: string | undefined = getEnvVar('HOST'); export const envAnalyticsId: string | undefined = getEnvVar('ANALYTICS_ID'); -export const envRecaptchaSecret: string | undefined = getEnvVar('RECAPTCHA_SECRET'); +export const envRecaptchaSecret: string | undefined = getEnvVar('RECAPTCHA_SITE_KEY'); export const envGoogleClientId: string | undefined = getEnvVar('GOOGLE_CLIENT_ID'); export const envSentryRelease: string | undefined = getEnvVar('SENTRY_RELEASE'); diff --git a/frontend/src/public/utils/getConfig.ts b/frontend/src/public/utils/getConfig.ts index fa291ca81..e94b10729 100644 --- a/frontend/src/public/utils/getConfig.ts +++ b/frontend/src/public/utils/getConfig.ts @@ -136,7 +136,7 @@ export function getConfig(): TConfig { "WSS_URL": process.env.WSS_URL, "HOST": process.env.HOST, "ANALYTICS_ID": process.env.ANALYTICS_ID, - "RECAPTCHA_SECRET": process.env.RECAPTCHA_SECRET, + "RECAPTCHA_SITE_KEY": process.env.RECAPTCHA_SITE_KEY, "GOOGLE_CLIENT_ID": process.env.GOOGLE_CLIENT_ID, "FIREBASE_VAPID_KEY": process.env.FIREBASE_VAPID_KEY, "FIREBASE_API_KEY": process.env.FIREBASE_API_KEY, From 68bd2163afdb5d718dfa865c08955e95df4a3ff9 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 22:22:55 +0500 Subject: [PATCH 06/12] 46741 refactor(frontend): remove dead Sentry webpack plugin and fix dev-only webpack compiler --- frontend/src/server/server.ts | 2 +- frontend/webpack.config.js | 23 ++--------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/frontend/src/server/server.ts b/frontend/src/server/server.ts index cdbd80a85..1adaa4e6f 100644 --- a/frontend/src/server/server.ts +++ b/frontend/src/server/server.ts @@ -30,11 +30,11 @@ const { export function initServer() { initSentryServer(); - const webpackCompiler = webpack(webpackConfig); const app = express(); const { host, port = 8000, formSubdomain, mainPage, firebase } = getConfig(); if (devMode) { + const webpackCompiler = webpack(webpackConfig); app.use(devMiddleware(webpackCompiler)); app.use( hotMiddleware(webpackCompiler, { diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 4d127e330..c6d8409e3 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -10,10 +10,6 @@ const devMode = NODE_ENV !== 'production'; const fontsDir = path.resolve(__dirname, './src/public/assets'); -const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; -const sentryRelease = process.env.SENTRY_RELEASE; -const enableSentryUpload = Boolean(sentryAuthToken && sentryRelease && !devMode); - module.exports = { entry: { main: devMode ? ['webpack-hot-middleware/client?path=/__webpack_hmr', './src/public/browser.tsx'] : './src/public/browser.tsx', @@ -22,7 +18,7 @@ module.exports = { cache: devMode ? { type: 'filesystem', buildDependencies: { config: [__filename] } } : false, - devtool: devMode ? 'eval-cheap-module-source-map' : undefined, + output: { filename: devMode ? '[name].js' : '[name].[contenthash].js', path: path.resolve(__dirname, './public'), @@ -116,22 +112,7 @@ module.exports = { new ForkTsCheckerWebpackPlugin(), // Uncomment to run Bundle Analyzer // new BundleAnalyzerPlugin(), - ...(enableSentryUpload - ? [ - (() => { - const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); - return sentryWebpackPlugin({ - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: sentryAuthToken, - release: { name: sentryRelease, inject: true }, - errorHandler: (err) => { - console.warn('Sentry source map upload failed:', err); - }, - }); - })(), - ] - : []), + { apply: (compiler) => { compiler.hooks.done.tap('DonePlugin', (stats) => { From 769e5214d5e37f8a2a7778b4d38c9d472c5c2594 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 19 May 2026 23:03:51 +0500 Subject: [PATCH 07/12] 46741 refactor(frontend): improve type safety and add tests for env config --- frontend/src/__tests__/webpack.config.test.ts | 159 +++++++ .../constants/__tests__/enviroment.test.ts | 408 ++++++++++++++++++ frontend/src/public/constants/enviroment.ts | 10 +- .../public/utils/__tests__/getConfig.test.ts | 245 +++++++++++ .../public/utils/__tests__/initSentry.test.ts | 190 ++++++++ frontend/src/public/utils/getConfig.ts | 41 +- 6 files changed, 1021 insertions(+), 32 deletions(-) create mode 100644 frontend/src/__tests__/webpack.config.test.ts create mode 100644 frontend/src/public/constants/__tests__/enviroment.test.ts create mode 100644 frontend/src/public/utils/__tests__/getConfig.test.ts create mode 100644 frontend/src/public/utils/__tests__/initSentry.test.ts diff --git a/frontend/src/__tests__/webpack.config.test.ts b/frontend/src/__tests__/webpack.config.test.ts new file mode 100644 index 000000000..d30d4588f --- /dev/null +++ b/frontend/src/__tests__/webpack.config.test.ts @@ -0,0 +1,159 @@ +// + +/** + * Tests for webpack.config.js behavioral changes: + * 1. No dotenv — .env is not read at build time + * 2. No envKeys — process.env is not forwarded to bundle + * 3. DefinePlugin only sets process.env.NODE_ENV + * 4. No MCS_RUN_ENV in HtmlWebpackPlugin + * 5. No Sentry webpack plugin + */ + +jest.mock('mini-css-extract-plugin', () => { + class MiniCssExtractPlugin { + static loader = {}; + } + return MiniCssExtractPlugin; +}); + +jest.mock('fork-ts-checker-webpack-plugin', () => { + return class ForkTsCheckerWebpackPlugin {}; +}); + +jest.mock('html-webpack-plugin', () => { + return class HtmlWebpackPlugin { + public options: Record; + constructor(options: Record) { + this.options = options; + } + }; +}); + +interface IWebpackPlugin { + constructor: { name: string }; + definitions?: Record; + options?: Record & { authToken?: string; filename?: string }; +} + +describe('webpack.config.js', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + const loadConfig = (envOverrides: Record = {}) => { + Object.assign(process.env, envOverrides); + return require('../../webpack.config.js') as { plugins: IWebpackPlugin[]; entry: unknown; mode: string; devtool: string }; + }; + + describe('DefinePlugin configuration', () => { + it('defines process.env.NODE_ENV via DefinePlugin (not full process.env)', () => { + process.env.NODE_ENV = 'production'; + const config = loadConfig({ NODE_ENV: 'production' }); + + const definePlugin = config.plugins.find( + (p: IWebpackPlugin) => p.constructor.name === 'DefinePlugin', + ); + + expect(definePlugin).toBeDefined(); + expect(definePlugin!.definitions).toEqual({ + 'process.env.NODE_ENV': '"production"', + }); + }); + + it('does not forward arbitrary env vars to the bundle', () => { + const config = loadConfig({ + NODE_ENV: 'development', + SECRET_KEY: 'should-not-be-in-bundle', + BACKEND_URL: 'https://example.com', + }); + + const definePlugin = config.plugins.find( + (p: IWebpackPlugin) => p.constructor.name === 'DefinePlugin', + ); + + expect(definePlugin!.definitions).not.toHaveProperty('process.env'); + const definedKeys = Object.keys(definePlugin!.definitions!); + expect(definedKeys).not.toContain('process.env'); + expect(definedKeys).toEqual(['process.env.NODE_ENV']); + }); + }); + + describe('HtmlWebpackPlugin configuration', () => { + it('main template does not have mcsRunEnv property', () => { + const config = loadConfig({ NODE_ENV: 'production' }); + + const htmlPlugins = config.plugins.filter( + (p: IWebpackPlugin) => p.constructor.name === 'HtmlWebpackPlugin', + ); + + const mainPlugin = htmlPlugins.find((p: IWebpackPlugin) => p.options?.filename === 'main.ejs'); + expect(mainPlugin).toBeDefined(); + expect(mainPlugin!.options).not.toHaveProperty('mcsRunEnv'); + }); + + it('forms template does not have mcsRunEnv property', () => { + const config = loadConfig({ NODE_ENV: 'production' }); + + const htmlPlugins = config.plugins.filter( + (p: IWebpackPlugin) => p.constructor.name === 'HtmlWebpackPlugin', + ); + + const formsPlugin = htmlPlugins.find((p: IWebpackPlugin) => p.options?.filename === 'forms.ejs'); + expect(formsPlugin).toBeDefined(); + expect(formsPlugin!.options).not.toHaveProperty('mcsRunEnv'); + }); + }); + + describe('Sentry webpack plugin removal', () => { + it('does not include sentryWebpackPlugin even when SENTRY_AUTH_TOKEN is set', () => { + const config = loadConfig({ + NODE_ENV: 'production', + SENTRY_AUTH_TOKEN: 'fake-token', + SENTRY_RELEASE: 'v1.0.0', + SENTRY_ORG: 'test-org', + SENTRY_PROJECT: 'test-project', + }); + + const pluginNames = config.plugins.map((p: IWebpackPlugin) => p.constructor.name); + expect(pluginNames).not.toContain('sentryWebpackPlugin'); + + const hasSentryPlugin = config.plugins.some( + (p: IWebpackPlugin) => + p.constructor.name.toLowerCase().includes('sentry') || + (p.options && p.options.authToken), + ); + expect(hasSentryPlugin).toBe(false); + }); + }); + + describe('dotenv removal', () => { + it('does not require dotenv module', () => { + const config = loadConfig({ NODE_ENV: 'production' }); + + // Config should load successfully without a .env file + expect(config).toBeDefined(); + expect(config.entry).toBeDefined(); + }); + }); + + describe('mode and devtool', () => { + it('sets mode to production when NODE_ENV is production', () => { + const config = loadConfig({ NODE_ENV: 'production' }); + + expect(config.mode).toBe('production'); + }); + + it('sets mode to development when NODE_ENV is development', () => { + const config = loadConfig({ NODE_ENV: 'development' }); + + expect(config.mode).toBe('development'); + }); + }); +}); diff --git a/frontend/src/public/constants/__tests__/enviroment.test.ts b/frontend/src/public/constants/__tests__/enviroment.test.ts new file mode 100644 index 000000000..dcf375c2c --- /dev/null +++ b/frontend/src/public/constants/__tests__/enviroment.test.ts @@ -0,0 +1,408 @@ +// + +describe('enviroment constants', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + delete (window as any).__pneumaticConfig; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + delete (window as any).__pneumaticConfig; + }); + + const loadModule = () => require('../enviroment'); + + describe('getEnvVar (via exported constants)', () => { + describe('when window.__pneumaticConfig has featureFlags', () => { + it('reads value from featureFlags over process.env', () => { + process.env.LANGUAGE_CODE = 'en'; + (window as any).__pneumaticConfig = { + config: { + featureFlags: { + LANGUAGE_CODE: 'ru', + }, + }, + }; + + const mod = loadModule(); + + expect(mod.envLanguageCode).toBe('ru'); + }); + + it('returns undefined from featureFlags when key is missing and process.env also missing', () => { + delete process.env.HOST; + (window as any).__pneumaticConfig = { + config: { + featureFlags: {}, + }, + }; + + const mod = loadModule(); + + expect(mod.envHost).toBeUndefined(); + }); + + it('falls back to process.env when key is not in featureFlags', () => { + process.env.SENTRY_DSN = 'https://sentry.example.com'; + (window as any).__pneumaticConfig = { + config: { + featureFlags: {}, + }, + }; + + const mod = loadModule(); + + expect(mod.envSentry).toBe('https://sentry.example.com'); + }); + }); + + describe('when __pneumaticConfig is absent on window', () => { + it('falls back to process.env', () => { + process.env.BACKEND_URL = 'http://api.local'; + delete (window as any).__pneumaticConfig; + + const mod = loadModule(); + + expect(mod.envBackendURL).toBe('http://api.local'); + }); + }); + + describe('when __pneumaticConfig.config has no featureFlags', () => { + it('falls back to process.env', () => { + process.env.WSS_URL = 'wss://ws.local'; + (window as any).__pneumaticConfig = { config: {} }; + + const mod = loadModule(); + + expect(mod.envWssURL).toBe('wss://ws.local'); + }); + }); + }); + + describe('boolean feature flags', () => { + describe('isEnvCaptcha', () => { + it('returns true when CAPTCHA is undefined (default enabled)', () => { + delete process.env.CAPTCHA; + + const mod = loadModule(); + + expect(mod.isEnvCaptcha).toBe(true); + }); + + it('returns false when CAPTCHA is "no"', () => { + process.env.CAPTCHA = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvCaptcha).toBe(false); + }); + + it('returns true when CAPTCHA is "yes"', () => { + process.env.CAPTCHA = 'yes'; + + const mod = loadModule(); + + expect(mod.isEnvCaptcha).toBe(true); + }); + }); + + describe('isEnvGoogleAuth', () => { + it('returns false when GOOGLE_AUTH is "no"', () => { + process.env.GOOGLE_AUTH = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvGoogleAuth).toBe(false); + }); + + it('returns true when GOOGLE_AUTH is undefined', () => { + delete process.env.GOOGLE_AUTH; + + const mod = loadModule(); + + expect(mod.isEnvGoogleAuth).toBe(true); + }); + }); + + describe('isEnvBilling', () => { + it('returns false when BILLING is "no"', () => { + process.env.BILLING = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvBilling).toBe(false); + }); + + it('returns true when BILLING is undefined', () => { + delete process.env.BILLING; + + const mod = loadModule(); + + expect(mod.isEnvBilling).toBe(true); + }); + }); + + describe('isEnvSignup', () => { + it('returns false when SIGNUP is "no"', () => { + process.env.SIGNUP = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvSignup).toBe(false); + }); + + it('returns true when SIGNUP is undefined', () => { + delete process.env.SIGNUP; + + const mod = loadModule(); + + expect(mod.isEnvSignup).toBe(true); + }); + }); + + describe('isEnvAi', () => { + it('returns false when AI is "no"', () => { + process.env.AI = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvAi).toBe(false); + }); + + it('returns true when AI is undefined', () => { + delete process.env.AI; + + const mod = loadModule(); + + expect(mod.isEnvAi).toBe(true); + }); + }); + + describe('isEnvPush', () => { + it('returns false when PUSH is "no"', () => { + process.env.PUSH = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvPush).toBe(false); + }); + + it('returns true when PUSH is undefined', () => { + delete process.env.PUSH; + + const mod = loadModule(); + + expect(mod.isEnvPush).toBe(true); + }); + }); + + describe('isEnvStorage', () => { + it('returns false when STORAGE is "no"', () => { + process.env.STORAGE = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvStorage).toBe(false); + }); + + it('returns true when STORAGE is undefined', () => { + delete process.env.STORAGE; + + const mod = loadModule(); + + expect(mod.isEnvStorage).toBe(true); + }); + }); + + describe('isEnvAnalytics', () => { + it('returns false when ANALYTICS is "no"', () => { + process.env.ANALYTICS = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvAnalytics).toBe(false); + }); + + it('returns true when ANALYTICS is undefined', () => { + delete process.env.ANALYTICS; + + const mod = loadModule(); + + expect(mod.isEnvAnalytics).toBe(true); + }); + }); + + describe('isEnvMsAuth', () => { + it('returns false when MS_AUTH is "no"', () => { + process.env.MS_AUTH = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvMsAuth).toBe(false); + }); + + it('returns true when MS_AUTH is undefined', () => { + delete process.env.MS_AUTH; + + const mod = loadModule(); + + expect(mod.isEnvMsAuth).toBe(true); + }); + }); + + describe('isEnvSSOAuth', () => { + it('returns false when SSO_AUTH is "no"', () => { + process.env.SSO_AUTH = 'no'; + + const mod = loadModule(); + + expect(mod.isEnvSSOAuth).toBe(false); + }); + + it('returns true when SSO_AUTH is undefined', () => { + delete process.env.SSO_AUTH; + + const mod = loadModule(); + + expect(mod.isEnvSSOAuth).toBe(true); + }); + }); + + it('reads boolean flag from featureFlags when available', () => { + process.env.AI = 'yes'; + (window as any).__pneumaticConfig = { + config: { + featureFlags: { + AI: 'no', + }, + }, + }; + + const mod = loadModule(); + + expect(mod.isEnvAi).toBe(false); + }); + }); + + describe('string environment variables', () => { + it('exports envSSOProvider from env', () => { + process.env.SSO_PROVIDER = 'okta'; + + const mod = loadModule(); + + expect(mod.envSSOProvider).toBe('okta'); + }); + + it('exports envHost from env', () => { + process.env.HOST = 'https://app.pneumatic.app'; + + const mod = loadModule(); + + expect(mod.envHost).toBe('https://app.pneumatic.app'); + }); + + it('exports envAnalyticsId from env', () => { + process.env.ANALYTICS_ID = 'UA-12345'; + + const mod = loadModule(); + + expect(mod.envAnalyticsId).toBe('UA-12345'); + }); + + it('exports envRecaptchaSiteKey from RECAPTCHA_SITE_KEY', () => { + process.env.RECAPTCHA_SITE_KEY = 'site-key-123'; + + const mod = loadModule(); + + expect(mod.envRecaptchaSiteKey).toBe('site-key-123'); + }); + + it('exports envGoogleClientId from env', () => { + process.env.GOOGLE_CLIENT_ID = 'google-id-123'; + + const mod = loadModule(); + + expect(mod.envGoogleClientId).toBe('google-id-123'); + }); + + it('exports envSentryRelease from env', () => { + process.env.SENTRY_RELEASE = 'v1.2.3'; + + const mod = loadModule(); + + expect(mod.envSentryRelease).toBe('v1.2.3'); + }); + }); + + describe('envDevMode', () => { + it('returns true when NODE_ENV is development', () => { + process.env.NODE_ENV = 'development'; + + const mod = loadModule(); + + expect(mod.envDevMode).toBe(true); + }); + + it('returns false when NODE_ENV is production', () => { + process.env.NODE_ENV = 'production'; + + const mod = loadModule(); + + expect(mod.envDevMode).toBe(false); + }); + }); + + describe('envFirebase', () => { + it('builds firebase config from env vars', () => { + process.env.FIREBASE_VAPID_KEY = 'vapid-key'; + process.env.FIREBASE_API_KEY = 'api-key'; + process.env.FIREBASE_AUTH_DOMAIN = 'auth.domain'; + process.env.FIREBASE_PROJECT_ID = 'project-id'; + process.env.FIREBASE_STORAGE_BUCKET = 'bucket'; + process.env.FIREBASE_SENDER_ID = 'sender-id'; + process.env.FIREBASE_APP_ID = 'app-id'; + process.env.FIREBASE_MEASUREMENT_ID = 'G-12345'; + + const mod = loadModule(); + + expect(mod.envFirebase).toEqual({ + vapidKey: 'vapid-key', + config: { + apiKey: 'api-key', + authDomain: 'auth.domain', + projectId: 'project-id', + storageBucket: 'bucket', + messagingSenderId: 'sender-id', + appId: 'app-id', + measurementId: 'G-12345', + }, + }); + }); + + it('reads firebase config from featureFlags when available', () => { + (window as any).__pneumaticConfig = { + config: { + featureFlags: { + FIREBASE_VAPID_KEY: 'browser-vapid', + FIREBASE_API_KEY: 'browser-api-key', + FIREBASE_AUTH_DOMAIN: 'browser-auth', + FIREBASE_PROJECT_ID: 'browser-project', + FIREBASE_STORAGE_BUCKET: 'browser-bucket', + FIREBASE_SENDER_ID: 'browser-sender', + FIREBASE_APP_ID: 'browser-app', + FIREBASE_MEASUREMENT_ID: 'browser-G-99', + }, + }, + }; + + const mod = loadModule(); + + expect(mod.envFirebase.vapidKey).toBe('browser-vapid'); + expect(mod.envFirebase.config.apiKey).toBe('browser-api-key'); + }); + }); +}); diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 3c8c94234..539d009b8 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -1,7 +1,13 @@ +interface IPneumaticConfig { + config?: { + featureFlags?: Record; + }; +} + const getEnvVar = (key: string): string | undefined => { if (typeof window !== 'undefined') { // eslint-disable-next-line no-underscore-dangle - const pneumaticConfig = (window as any).__pneumaticConfig; + const pneumaticConfig = (window as unknown as { __pneumaticConfig?: IPneumaticConfig }).__pneumaticConfig; if (pneumaticConfig?.config?.featureFlags && pneumaticConfig.config.featureFlags[key] !== undefined) { return pneumaticConfig.config.featureFlags[key]; } @@ -36,7 +42,7 @@ export const envSSOProvider: string | undefined = getEnvVar('SSO_PROVIDER'); export const envHost: string | undefined = getEnvVar('HOST'); export const envAnalyticsId: string | undefined = getEnvVar('ANALYTICS_ID'); -export const envRecaptchaSecret: string | undefined = getEnvVar('RECAPTCHA_SITE_KEY'); +export const envRecaptchaSiteKey: string | undefined = getEnvVar('RECAPTCHA_SITE_KEY'); export const envGoogleClientId: string | undefined = getEnvVar('GOOGLE_CLIENT_ID'); export const envSentryRelease: string | undefined = getEnvVar('SENTRY_RELEASE'); diff --git a/frontend/src/public/utils/__tests__/getConfig.test.ts b/frontend/src/public/utils/__tests__/getConfig.test.ts new file mode 100644 index 000000000..fc9bdca78 --- /dev/null +++ b/frontend/src/public/utils/__tests__/getConfig.test.ts @@ -0,0 +1,245 @@ +// + +jest.mock('../../../../config/common.json', () => ({ + api: { + urls: { + getUser: '/auth/context', + getToken: '/auth/signin', + }, + }, + exposedToBrowser: [ + 'host', + 'api.publicUrl', + 'api.wsPublicUrl', + 'api.urls', + 'analyticsId', + 'mainPage', + 'formSubdomain', + 'recaptchaSecret', + 'firebase', + 'featureFlags', + ], +})); + +jest.mock('lodash.merge', () => { + const deepMerge = (target: any, ...sources: any[]): any => { + const result = { ...target }; + for (const source of sources) { + if (!source) continue; + for (const key of Object.keys(source)) { + if ( + typeof source[key] === 'object' && + source[key] !== null && + !Array.isArray(source[key]) && + typeof result[key] === 'object' && + result[key] !== null + ) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + } + return result; + }; + return deepMerge; +}); + +jest.mock('../../../server/utils/helpers', () => ({ + get: (obj: any, path: string) => { + return path.split('.').reduce((acc: any, part: string) => acc && acc[part], obj); + }, + set: (obj: any, path: string, value: any) => { + const parts = path.split('.'); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current)) current[parts[i]] = {}; + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; + }, +})); + +jest.mock('../../types/user', () => ({})); +jest.mock('../../types/redux', () => ({})); +jest.mock('../../redux/pages/types', () => ({})); + +describe('getConfig', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + delete (window as any).__pneumaticConfig; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + delete (window as any).__pneumaticConfig; + }); + + const loadModule = () => require('../getConfig'); + + describe('getConfig()', () => { + it('includes featureFlags in the returned config', () => { + process.env.CAPTCHA = 'yes'; + process.env.AI = 'no'; + process.env.BILLING = 'yes'; + process.env.SENTRY_RELEASE = 'v2.0.0'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags).toBeDefined(); + expect(config.featureFlags.CAPTCHA).toBe('yes'); + expect(config.featureFlags.AI).toBe('no'); + expect(config.featureFlags.BILLING).toBe('yes'); + expect(config.featureFlags.SENTRY_RELEASE).toBe('v2.0.0'); + }); + + it('maps RECAPTCHA_SITE_KEY env to featureFlags', () => { + process.env.RECAPTCHA_SITE_KEY = 'recaptcha-key-abc'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags.RECAPTCHA_SITE_KEY).toBe('recaptcha-key-abc'); + }); + + it('maps all boolean feature flags to featureFlags object', () => { + process.env.GOOGLE_AUTH = 'no'; + process.env.MS_AUTH = 'yes'; + process.env.SSO_AUTH = 'no'; + process.env.SIGNUP = 'yes'; + process.env.PUSH = 'no'; + process.env.STORAGE = 'yes'; + process.env.ANALYTICS = 'no'; + process.env.SSO_PROVIDER = 'okta'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags.GOOGLE_AUTH).toBe('no'); + expect(config.featureFlags.MS_AUTH).toBe('yes'); + expect(config.featureFlags.SSO_AUTH).toBe('no'); + expect(config.featureFlags.SIGNUP).toBe('yes'); + expect(config.featureFlags.PUSH).toBe('no'); + expect(config.featureFlags.STORAGE).toBe('yes'); + expect(config.featureFlags.ANALYTICS).toBe('no'); + expect(config.featureFlags.SSO_PROVIDER).toBe('okta'); + }); + + it('maps connection env vars to featureFlags', () => { + process.env.LANGUAGE_CODE = 'ru'; + process.env.BACKEND_URL = 'http://api.local'; + process.env.SENTRY_DSN = 'https://sentry.example.com'; + process.env.WSS_URL = 'wss://ws.local'; + process.env.HOST = 'https://app.local'; + process.env.ANALYTICS_ID = 'UA-999'; + process.env.GOOGLE_CLIENT_ID = 'google-id'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags.LANGUAGE_CODE).toBe('ru'); + expect(config.featureFlags.BACKEND_URL).toBe('http://api.local'); + expect(config.featureFlags.SENTRY_DSN).toBe('https://sentry.example.com'); + expect(config.featureFlags.WSS_URL).toBe('wss://ws.local'); + expect(config.featureFlags.HOST).toBe('https://app.local'); + expect(config.featureFlags.ANALYTICS_ID).toBe('UA-999'); + expect(config.featureFlags.GOOGLE_CLIENT_ID).toBe('google-id'); + }); + + it('maps Firebase env vars to featureFlags', () => { + process.env.FIREBASE_VAPID_KEY = 'vapid'; + process.env.FIREBASE_API_KEY = 'api-key'; + process.env.FIREBASE_AUTH_DOMAIN = 'auth-domain'; + process.env.FIREBASE_PROJECT_ID = 'proj'; + process.env.FIREBASE_STORAGE_BUCKET = 'bucket'; + process.env.FIREBASE_SENDER_ID = 'sender'; + process.env.FIREBASE_APP_ID = 'app'; + process.env.FIREBASE_MEASUREMENT_ID = 'G-1'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags.FIREBASE_VAPID_KEY).toBe('vapid'); + expect(config.featureFlags.FIREBASE_API_KEY).toBe('api-key'); + expect(config.featureFlags.FIREBASE_AUTH_DOMAIN).toBe('auth-domain'); + expect(config.featureFlags.FIREBASE_PROJECT_ID).toBe('proj'); + expect(config.featureFlags.FIREBASE_STORAGE_BUCKET).toBe('bucket'); + expect(config.featureFlags.FIREBASE_SENDER_ID).toBe('sender'); + expect(config.featureFlags.FIREBASE_APP_ID).toBe('app'); + expect(config.featureFlags.FIREBASE_MEASUREMENT_ID).toBe('G-1'); + }); + + it('returns undefined for unset featureFlags keys', () => { + delete process.env.SENTRY_RELEASE; + delete process.env.SSO_PROVIDER; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.featureFlags.SENTRY_RELEASE).toBeUndefined(); + expect(config.featureFlags.SSO_PROVIDER).toBeUndefined(); + }); + + it('still includes RECAPTCHA_SITE_KEY in recaptchaSecret field', () => { + process.env.RECAPTCHA_SITE_KEY = 'recaptcha-xyz'; + + const { getConfig } = loadModule(); + const config = getConfig(); + + expect(config.recaptchaSecret).toBe('recaptcha-xyz'); + }); + }); + + describe('serverConfigToBrowser()', () => { + it('includes featureFlags in browser config', () => { + process.env.CAPTCHA = 'no'; + process.env.AI = 'yes'; + + const { serverConfigToBrowser } = loadModule(); + const browserConfig = serverConfigToBrowser(); + + expect(browserConfig.featureFlags).toBeDefined(); + expect(browserConfig.featureFlags.CAPTCHA).toBe('no'); + expect(browserConfig.featureFlags.AI).toBe('yes'); + }); + }); + + describe('getBrowserConfig()', () => { + it('returns window.__pneumaticConfig', () => { + const mockConfig = { + config: { host: 'test', featureFlags: { AI: 'yes' } }, + user: {}, + invitedUser: {}, + pages: {}, + }; + (window as any).__pneumaticConfig = mockConfig; + + const { getBrowserConfig } = loadModule(); + + expect(getBrowserConfig()).toBe(mockConfig); + }); + }); + + describe('getBrowserConfigEnv()', () => { + it('returns config from window.__pneumaticConfig', () => { + const innerConfig = { host: 'localhost', featureFlags: { PUSH: 'no' } }; + (window as any).__pneumaticConfig = { config: innerConfig }; + + const { getBrowserConfigEnv } = loadModule(); + + expect(getBrowserConfigEnv()).toBe(innerConfig); + }); + + it('returns empty object when no browser config', () => { + delete (window as any).__pneumaticConfig; + + const { getBrowserConfigEnv } = loadModule(); + + expect(getBrowserConfigEnv()).toEqual({}); + }); + }); +}); diff --git a/frontend/src/public/utils/__tests__/initSentry.test.ts b/frontend/src/public/utils/__tests__/initSentry.test.ts new file mode 100644 index 000000000..111fe36cf --- /dev/null +++ b/frontend/src/public/utils/__tests__/initSentry.test.ts @@ -0,0 +1,190 @@ +// + +jest.mock('@sentry/react', () => ({ + init: jest.fn(), + captureException: jest.fn(), +})); + +jest.mock('../sentryCapture', () => ({ + setSentryCapture: jest.fn(), +})); + +jest.mock('../../constants/defaultValues', () => ({ + DEV_SENTRY_DSN: 'https://staging-dsn@sentry.io/123', + PROD_SENTRY_DSN: 'https://prod-dsn@sentry.io/456', +})); + +describe('initSentry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('release from envSentryRelease', () => { + it('passes envSentryRelease as release to Sentry.init when available', () => { + jest.doMock('../../constants/enviroment', () => ({ + envSentryRelease: 'v3.5.0', + })); + jest.resetModules(); + + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'prod' as const }); + initSentry(getConfig, 'main'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + release: 'v3.5.0', + }), + ); + }); + + it('passes undefined release when envSentryRelease is undefined', () => { + jest.doMock('../../constants/enviroment', () => ({ + envSentryRelease: undefined, + })); + jest.resetModules(); + + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'staging' as const }); + initSentry(getConfig, 'forms'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + release: undefined, + }), + ); + }); + }); + + describe('environment routing', () => { + beforeEach(() => { + jest.doMock('../../constants/enviroment', () => ({ + envSentryRelease: undefined, + })); + jest.resetModules(); + }); + + it('does not init Sentry for local environment', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'local' as const }); + initSentry(getConfig, 'main'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).not.toHaveBeenCalled(); + }); + + it('inits Sentry with staging DSN for staging environment', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'staging' as const }); + initSentry(getConfig, 'main'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://staging-dsn@sentry.io/123', + environment: 'staging', + }), + ); + }); + + it('inits Sentry with prod DSN for prod environment', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'prod' as const }); + initSentry(getConfig, 'forms'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://prod-dsn@sentry.io/456', + environment: 'prod', + }), + ); + }); + + it('defaults to local when env is not provided', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({}); + initSentry(getConfig, 'main'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).not.toHaveBeenCalled(); + }); + }); + + describe('app tag', () => { + beforeEach(() => { + jest.doMock('../../constants/enviroment', () => ({ + envSentryRelease: undefined, + })); + jest.resetModules(); + }); + + it('sets app tag to main', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'prod' as const }); + initSentry(getConfig, 'main'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + initialScope: { + tags: { app: 'main' }, + }, + }), + ); + }); + + it('sets app tag to forms', () => { + const { initSentry } = require('../initSentry'); + + const getConfig = () => ({ env: 'staging' as const }); + initSentry(getConfig, 'forms'); + + const sentryMock = require('@sentry/react'); + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + initialScope: { + tags: { app: 'forms' }, + }, + }), + ); + }); + }); + + describe('setSentryCapture', () => { + beforeEach(() => { + jest.doMock('../../constants/enviroment', () => ({ + envSentryRelease: undefined, + })); + jest.resetModules(); + }); + + it('calls setSentryCapture on successful init', () => { + const { initSentry } = require('../initSentry'); + const { setSentryCapture } = require('../sentryCapture'); + + const getConfig = () => ({ env: 'prod' as const }); + initSentry(getConfig, 'main'); + + expect(setSentryCapture).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('does not call setSentryCapture when dsn is null (local)', () => { + const { initSentry } = require('../initSentry'); + const { setSentryCapture } = require('../sentryCapture'); + + const getConfig = () => ({ env: 'local' as const }); + initSentry(getConfig, 'main'); + + expect(setSentryCapture).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/public/utils/getConfig.ts b/frontend/src/public/utils/getConfig.ts index e94b10729..1dd0cebcd 100644 --- a/frontend/src/public/utils/getConfig.ts +++ b/frontend/src/public/utils/getConfig.ts @@ -118,36 +118,17 @@ export function getConfig(): TConfig { "measurementId": FIREBASE_MEASUREMENT_ID } }, - "featureFlags": { - "CAPTCHA": process.env.CAPTCHA, - "GOOGLE_AUTH": process.env.GOOGLE_AUTH, - "MS_AUTH": process.env.MS_AUTH, - "SSO_AUTH": process.env.SSO_AUTH, - "SIGNUP": process.env.SIGNUP, - "BILLING": process.env.BILLING, - "AI": process.env.AI, - "PUSH": process.env.PUSH, - "STORAGE": process.env.STORAGE, - "ANALYTICS": process.env.ANALYTICS, - "SSO_PROVIDER": process.env.SSO_PROVIDER, - "LANGUAGE_CODE": process.env.LANGUAGE_CODE, - "BACKEND_URL": process.env.BACKEND_URL, - "SENTRY_DSN": process.env.SENTRY_DSN, - "WSS_URL": process.env.WSS_URL, - "HOST": process.env.HOST, - "ANALYTICS_ID": process.env.ANALYTICS_ID, - "RECAPTCHA_SITE_KEY": process.env.RECAPTCHA_SITE_KEY, - "GOOGLE_CLIENT_ID": process.env.GOOGLE_CLIENT_ID, - "FIREBASE_VAPID_KEY": process.env.FIREBASE_VAPID_KEY, - "FIREBASE_API_KEY": process.env.FIREBASE_API_KEY, - "FIREBASE_AUTH_DOMAIN": process.env.FIREBASE_AUTH_DOMAIN, - "FIREBASE_PROJECT_ID": process.env.FIREBASE_PROJECT_ID, - "FIREBASE_STORAGE_BUCKET": process.env.FIREBASE_STORAGE_BUCKET, - "FIREBASE_SENDER_ID": process.env.FIREBASE_SENDER_ID, - "FIREBASE_APP_ID": process.env.FIREBASE_APP_ID, - "FIREBASE_MEASUREMENT_ID": process.env.FIREBASE_MEASUREMENT_ID, - "SENTRY_RELEASE": process.env.SENTRY_RELEASE - } + "featureFlags": Object.fromEntries( + [ + 'CAPTCHA', 'GOOGLE_AUTH', 'MS_AUTH', 'SSO_AUTH', 'SIGNUP', 'BILLING', + 'AI', 'PUSH', 'STORAGE', 'ANALYTICS', 'SSO_PROVIDER', 'LANGUAGE_CODE', + 'BACKEND_URL', 'SENTRY_DSN', 'WSS_URL', 'HOST', 'ANALYTICS_ID', + 'RECAPTCHA_SITE_KEY', 'GOOGLE_CLIENT_ID', 'FIREBASE_VAPID_KEY', + 'FIREBASE_API_KEY', 'FIREBASE_AUTH_DOMAIN', 'FIREBASE_PROJECT_ID', + 'FIREBASE_STORAGE_BUCKET', 'FIREBASE_SENDER_ID', 'FIREBASE_APP_ID', + 'FIREBASE_MEASUREMENT_ID', 'SENTRY_RELEASE', + ].map(key => [key, process.env[key]]) + ) }); } From 58883a0db128c537b67800a0f0db0a05064ba923 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Fri, 22 May 2026 13:37:52 +0500 Subject: [PATCH 08/12] 46741 refactor(frontend): apply code review fixes for runtime env migration --- backend/Dockerfile | 2 - .../constants/__tests__/enviroment.test.ts | 2 +- frontend/src/public/constants/enviroment.ts | 81 +++++++++++-------- frontend/src/server/server.ts | 2 +- frontend/webpack.config.js | 28 +++++-- 5 files changed, 70 insertions(+), 45 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 08e8d9f8f..eb5c3c524 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,5 +25,3 @@ COPY ./pyproject.toml ./poetry.lock /tmp/ WORKDIR /pneumatic_backend RUN poetry config virtualenvs.create false && \ poetry install --without dev --no-root --no-interaction --directory /tmp - -COPY . . diff --git a/frontend/src/public/constants/__tests__/enviroment.test.ts b/frontend/src/public/constants/__tests__/enviroment.test.ts index dcf375c2c..890e41c28 100644 --- a/frontend/src/public/constants/__tests__/enviroment.test.ts +++ b/frontend/src/public/constants/__tests__/enviroment.test.ts @@ -16,7 +16,7 @@ describe('enviroment constants', () => { const loadModule = () => require('../enviroment'); - describe('getEnvVar (via exported constants)', () => { + describe('getEnv (via exported constants)', () => { describe('when window.__pneumaticConfig has featureFlags', () => { it('reads value from featureFlags over process.env', () => { process.env.LANGUAGE_CODE = 'en'; diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 539d009b8..e8b2702bb 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -4,7 +4,7 @@ interface IPneumaticConfig { }; } -const getEnvVar = (key: string): string | undefined => { +const getEnv = (key: string): string | undefined => { if (typeof window !== 'undefined') { // eslint-disable-next-line no-underscore-dangle const pneumaticConfig = (window as unknown as { __pneumaticConfig?: IPneumaticConfig }).__pneumaticConfig; @@ -12,50 +12,63 @@ const getEnvVar = (key: string): string | undefined => { return pneumaticConfig.config.featureFlags[key]; } } - if (typeof process !== 'undefined' && process.env) { - return process.env[key]; - } - return undefined; + + return typeof process !== 'undefined' && process.env + ? process.env[key] + : undefined; }; -export const envLanguageCode: string | undefined = getEnvVar('LANGUAGE_CODE'); -export const envBackendURL: string | undefined = getEnvVar('BACKEND_URL'); -export const envSentry: string | undefined = getEnvVar('SENTRY_DSN'); -export const envWssURL: string | undefined = getEnvVar('WSS_URL'); +export const envLanguageCode: string | undefined = getEnv('LANGUAGE_CODE'); +export const envBackendURL: string | undefined = getEnv('BACKEND_URL'); +export const envSentry: string | undefined = getEnv('SENTRY_DSN'); +export const envWssURL: string | undefined = getEnv('WSS_URL'); export const envDevMode: boolean = process.env.NODE_ENV === 'development'; -export const isEnvCaptcha: boolean = getEnvVar('CAPTCHA') !== 'no'; -export const isEnvGoogleAuth: boolean = getEnvVar('GOOGLE_AUTH') !== 'no'; -export const isEnvMsAuth: boolean = getEnvVar('MS_AUTH') !== 'no'; -export const isEnvSSOAuth: boolean = getEnvVar('SSO_AUTH') !== 'no'; -export const isEnvSignup: boolean = getEnvVar('SIGNUP') !== 'no'; -export const isEnvBilling: boolean = getEnvVar('BILLING') !== 'no'; -export const isEnvAi: boolean = getEnvVar('AI') !== 'no'; -export const isEnvPush: boolean = getEnvVar('PUSH') !== 'no'; -export const isEnvStorage: boolean = getEnvVar('STORAGE') !== 'no'; -export const isEnvAnalytics: boolean = getEnvVar('ANALYTICS') !== 'no'; -export const envSSOProvider: string | undefined = getEnvVar('SSO_PROVIDER'); +export const isEnvCaptcha: boolean = getEnv('CAPTCHA') !== 'no'; +export const isEnvGoogleAuth: boolean = getEnv('GOOGLE_AUTH') !== 'no'; +export const isEnvMsAuth: boolean = getEnv('MS_AUTH') !== 'no'; +export const isEnvSSOAuth: boolean = getEnv('SSO_AUTH') !== 'no'; +export const isEnvSignup: boolean = getEnv('SIGNUP') !== 'no'; +export const isEnvBilling: boolean = getEnv('BILLING') !== 'no'; +export const isEnvAi: boolean = getEnv('AI') !== 'no'; +export const isEnvPush: boolean = getEnv('PUSH') !== 'no'; +export const isEnvStorage: boolean = getEnv('STORAGE') !== 'no'; +export const isEnvAnalytics: boolean = getEnv('ANALYTICS') !== 'no'; +export const envSSOProvider: string | undefined = getEnv('SSO_PROVIDER'); // New ENV -export const envHost: string | undefined = getEnvVar('HOST'); -export const envAnalyticsId: string | undefined = getEnvVar('ANALYTICS_ID'); -export const envRecaptchaSiteKey: string | undefined = getEnvVar('RECAPTCHA_SITE_KEY'); +export const envHost: string | undefined = getEnv('HOST'); +export const envAnalyticsId: string | undefined = getEnv('ANALYTICS_ID'); +export const envRecaptchaSiteKey: string | undefined = getEnv('RECAPTCHA_SITE_KEY'); + +export const envGoogleClientId: string | undefined = getEnv('GOOGLE_CLIENT_ID'); +export const envSentryRelease: string | undefined = getEnv('SENTRY_RELEASE'); -export const envGoogleClientId: string | undefined = getEnvVar('GOOGLE_CLIENT_ID'); -export const envSentryRelease: string | undefined = getEnvVar('SENTRY_RELEASE'); +interface IFirebaseConfig { + vapidKey: string | undefined; + config: { + apiKey: string | undefined; + authDomain: string | undefined; + projectId: string | undefined; + storageBucket: string | undefined; + messagingSenderId: string | undefined; + appId: string | undefined; + measurementId: string | undefined; + }; +} -export const envFirebase: any = { - vapidKey: getEnvVar('FIREBASE_VAPID_KEY'), +export const envFirebase: IFirebaseConfig = { + vapidKey: getEnv('FIREBASE_VAPID_KEY'), config: { - apiKey: getEnvVar('FIREBASE_API_KEY'), - authDomain: getEnvVar('FIREBASE_AUTH_DOMAIN'), - projectId: getEnvVar('FIREBASE_PROJECT_ID'), - storageBucket: getEnvVar('FIREBASE_STORAGE_BUCKET'), - messagingSenderId: getEnvVar('FIREBASE_SENDER_ID'), - appId: getEnvVar('FIREBASE_APP_ID'), - measurementId: getEnvVar('FIREBASE_MEASUREMENT_ID') + apiKey: getEnv('FIREBASE_API_KEY'), + authDomain: getEnv('FIREBASE_AUTH_DOMAIN'), + projectId: getEnv('FIREBASE_PROJECT_ID'), + storageBucket: getEnv('FIREBASE_STORAGE_BUCKET'), + messagingSenderId: getEnv('FIREBASE_SENDER_ID'), + appId: getEnv('FIREBASE_APP_ID'), + measurementId: getEnv('FIREBASE_MEASUREMENT_ID') } }; diff --git a/frontend/src/server/server.ts b/frontend/src/server/server.ts index 1adaa4e6f..cdbd80a85 100644 --- a/frontend/src/server/server.ts +++ b/frontend/src/server/server.ts @@ -30,11 +30,11 @@ const { export function initServer() { initSentryServer(); + const webpackCompiler = webpack(webpackConfig); const app = express(); const { host, port = 8000, formSubdomain, mainPage, firebase } = getConfig(); if (devMode) { - const webpackCompiler = webpack(webpackConfig); app.use(devMiddleware(webpackCompiler)); app.use( hotMiddleware(webpackCompiler, { diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index c6d8409e3..9d0bba5af 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -10,6 +10,10 @@ const devMode = NODE_ENV !== 'production'; const fontsDir = path.resolve(__dirname, './src/public/assets'); +const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; +const sentryRelease = process.env.SENTRY_RELEASE; +const enableSentryUpload = Boolean(sentryAuthToken && sentryRelease && !devMode); + module.exports = { entry: { main: devMode ? ['webpack-hot-middleware/client?path=/__webpack_hmr', './src/public/browser.tsx'] : './src/public/browser.tsx', @@ -18,7 +22,7 @@ module.exports = { cache: devMode ? { type: 'filesystem', buildDependencies: { config: [__filename] } } : false, - + devtool: devMode ? 'eval-cheap-module-source-map' : undefined, output: { filename: devMode ? '[name].js' : '[name].[contenthash].js', path: path.resolve(__dirname, './public'), @@ -112,6 +116,22 @@ module.exports = { new ForkTsCheckerWebpackPlugin(), // Uncomment to run Bundle Analyzer // new BundleAnalyzerPlugin(), + ...(enableSentryUpload + ? [ + (() => { + const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); + return sentryWebpackPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: sentryAuthToken, + release: { name: sentryRelease, inject: true }, + errorHandler: (err) => { + console.warn('Sentry source map upload failed:', err); + }, + }); + })(), + ] + : []), { apply: (compiler) => { @@ -135,10 +155,4 @@ module.exports = { }, }, mode: NODE_ENV, - devtool: devMode ? 'eval-cheap-module-source-map' : 'hidden-source-map', }; - - - - - From 6323286dfb596f98567d920567e9605ca37fc483 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Fri, 22 May 2026 13:59:44 +0500 Subject: [PATCH 09/12] 46741 fix(firebase): revert env key to FIREBASE_MESSAGING_SENDER_ID --- frontend/src/public/constants/enviroment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index e8b2702bb..695b40f84 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -67,7 +67,7 @@ export const envFirebase: IFirebaseConfig = { authDomain: getEnv('FIREBASE_AUTH_DOMAIN'), projectId: getEnv('FIREBASE_PROJECT_ID'), storageBucket: getEnv('FIREBASE_STORAGE_BUCKET'), - messagingSenderId: getEnv('FIREBASE_SENDER_ID'), + messagingSenderId: getEnv('FIREBASE_MESSAGING_SENDER_ID'), appId: getEnv('FIREBASE_APP_ID'), measurementId: getEnv('FIREBASE_MEASUREMENT_ID') } From ca4499bfc0aaf863a3579ec71ff2505a2559bc97 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Fri, 22 May 2026 14:17:25 +0500 Subject: [PATCH 10/12] Revert "46741 fix(firebase): revert env key to FIREBASE_MESSAGING_SENDER_ID" This reverts commit 6323286dfb596f98567d920567e9605ca37fc483. --- frontend/src/public/constants/enviroment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/public/constants/enviroment.ts b/frontend/src/public/constants/enviroment.ts index 695b40f84..e8b2702bb 100644 --- a/frontend/src/public/constants/enviroment.ts +++ b/frontend/src/public/constants/enviroment.ts @@ -67,7 +67,7 @@ export const envFirebase: IFirebaseConfig = { authDomain: getEnv('FIREBASE_AUTH_DOMAIN'), projectId: getEnv('FIREBASE_PROJECT_ID'), storageBucket: getEnv('FIREBASE_STORAGE_BUCKET'), - messagingSenderId: getEnv('FIREBASE_MESSAGING_SENDER_ID'), + messagingSenderId: getEnv('FIREBASE_SENDER_ID'), appId: getEnv('FIREBASE_APP_ID'), measurementId: getEnv('FIREBASE_MEASUREMENT_ID') } From 77238495a024211494417b96e583ccdfd800fbbd Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 26 May 2026 04:35:33 +0500 Subject: [PATCH 11/12] 46741 chore(frontend): use explicit npm script for client build --- frontend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ab994e74a..a03a59c77 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -23,6 +23,6 @@ RUN npm ci --legacy-peer-deps ADD . /pneumatic_frontend/ # Build webpack bundle at image build time (env-independent since runtime config refactor) -RUN NODE_ENV=production npx webpack +RUN npm run build-client:prod CMD ["pm2-runtime", "start", "pm2.json"] From 3cfe1dcfd59367171db21936da860857503d65c3 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Thu, 28 May 2026 23:40:11 +0500 Subject: [PATCH 12/12] 46741 fix(frontend): add NODE_OPTIONS to Dockerfile for webpack build memory --- frontend/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a03a59c77..034ad548c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -23,6 +23,7 @@ RUN npm ci --legacy-peer-deps ADD . /pneumatic_frontend/ # Build webpack bundle at image build time (env-independent since runtime config refactor) +ENV NODE_OPTIONS="--max-old-space-size=3072" RUN npm run build-client:prod CMD ["pm2-runtime", "start", "pm2.json"]