From 12f106128f4c56f41dfa0f461876b7beac9f8796 Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:35:19 +0200 Subject: [PATCH 1/6] refactor: refactor env variable validation and config --- app/config/client.js | 110 +++++------ app/config/envConfig.js | 104 ++++++++++ app/config/envConfig.ts | 138 +++++++++++++ app/config/helper.js | 8 - app/config/server.js | 426 ++++++++++++++++++---------------------- 5 files changed, 485 insertions(+), 301 deletions(-) create mode 100644 app/config/envConfig.js create mode 100644 app/config/envConfig.ts delete mode 100644 app/config/helper.js diff --git a/app/config/client.js b/app/config/client.js index a35c8336..75efe971 100644 --- a/app/config/client.js +++ b/app/config/client.js @@ -1,65 +1,59 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - const { z } = require('zod'); -const { someOrAllNotSet } = require('./helper'); const { env } = require('next-runtime-env'); +const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig'); + +const clientEnvConfig = createEnvConfig({ + variables: { + // DO NOT remove the stage environment variable from this config + STAGE: { + defaultRule: z.enum(['development', 'test', 'build', 'production', 'lint']).default('development'), + required: true, + }, + + // Strapi + NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: { + defaultRule: z.string().default(''), + required: true, + }, + NEXT_PUBLIC_STRAPI_ENDPOINT: { + defaultRule: z.string().default(''), + required: true, + }, + + // Push notifications + NEXT_PUBLIC_VAPID_PUBLIC_KEY: { + defaultRule: z.string().default(''), + required: true, + }, + + // Application insights + NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: { + defaultRule: z.string().optional(), + }, + NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: { + defaultRule: z.string().optional(), + }, + + // Version info + NEXT_PUBLIC_BUILDTIMESTAMP: { + defaultRule: z.string().optional(), + }, + NEXT_PUBLIC_CI_COMMIT_SHA: { + defaultRule: z.string().optional(), + }, + }, + groups: [ + { + variables: ['NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'], + mode: 'none_or_all', + errorMessage: 'All Application Insights variables are required to enable Azure Application Insights', + }, + ], +}); -const Config = z - .object({ - STAGE: z.enum(['development', 'test', 'build', 'production']).default('development'), - NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT must be set!' }), - }) - .default(''), - NEXT_PUBLIC_STRAPI_ENDPOINT: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_STRAPI_ENDPOINT must be set!' }), - }) - .default(''), - NEXT_PUBLIC_VAPID_PUBLIC_KEY: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_VAPID_PUBLIC_KEY must be set!' }), - }) - .default(''), - NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING must be set!' }), - }) - .default(''), - NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY must be set!' }), - }) - .default(''), - NEXT_PUBLIC_BUILDTIMESTAMP: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_BUILDTIMESTAMP must be set!' }), - }) - .default(''), - NEXT_PUBLIC_CI_COMMIT_SHA: z - .string({ - errorMap: () => ({ message: 'NEXT_PUBLIC_CI_COMMIT_SHA must be set!' }), - }) - .default(''), - }) - .superRefine((values, ctx) => { - const { NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY, STAGE } = values; - if (STAGE !== 'build') { - // The checking of the counterparts is done in the server config to limit the possibility of cross-imports. - // Assignment to default as - const appInsights = [NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY]; - if (someOrAllNotSet(appInsights)) { - ctx.addIssue({ - message: 'All Application Insights variables are required to enable Azure Application Insights.', - code: z.ZodIssueCode.custom, - path: ['NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'], - }); - } - } - }); +const schema = createZodSchemaFromEnvConfig(clientEnvConfig); -const clientConfig = Config.safeParse({ +const clientConfig = schema.safeParse({ NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: env('NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING'), NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: env('NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'), NEXT_PUBLIC_VAPID_PUBLIC_KEY: env('NEXT_PUBLIC_VAPID_PUBLIC_KEY'), diff --git a/app/config/envConfig.js b/app/config/envConfig.js new file mode 100644 index 00000000..8add5209 --- /dev/null +++ b/app/config/envConfig.js @@ -0,0 +1,104 @@ +const { config } = require('process'); +const { z } = require('zod'); + +// Returns an environment config +const createEnvConfig = (props) => { + return { groups: props.groups, variables: props.variables }; +}; + +// Creates a zod schema (z.Object({...})) based on an environment configuration +const createZodSchemaFromEnvConfig = (config) => { + const zodSchema = {}; + + Object.keys(config.variables).map((variable) => { + const key = variable; + const variableConfig = config.variables[key]; + const defaultRule = variableConfig.defaultRule; + zodSchema[variable] = defaultRule; + }); + + return z.object(zodSchema); +}; + +// Adds stage (build/lint/...) specific environment variable validation to a zod schema +const addVariableValidation = (schema, envConfig) => { + const valueExists = (value) => value?.toString().trim().length > 0; + + return schema.superRefine((values, ctx) => { + const configKeys = Object.keys(envConfig.variables); + const currentStage = values.STAGE; // lint/build/production/... + + // Validate single environment variables one-by-one + configKeys.forEach((configKey) => { + const variableConfig = envConfig.variables[configKey]; + + // Check if env variable should be checked for this stage + const noStageApplies = variableConfig.stages?.every((stage) => stage !== currentStage) ?? false; + + if (noStageApplies) { + return; + } + + const value = values[configKey]; + const valueIsSet = valueExists(value); + + // Optional env variables may be omitted + if (!variableConfig.required && !valueIsSet) { + return; + } + + // Check that required env variables are set + if (!valueIsSet) { + ctx.addIssue({ + message: `Environment variable '${String(configKey)}' is required, but not set`, + code: z.ZodIssueCode.custom, + }); + return; + } + + // Check env variable against regex if configured + if (!variableConfig.regex) { + return; + } + + const matchesRegex = variableConfig.regex.test(value); + + if (!matchesRegex) { + ctx.addIssue({ + message: `Environment variable '${String(configKey)}' has an invalid format`, + code: z.ZodIssueCode.custom, + }); + } + }); + + // Validate env variable groups + envConfig.groups.forEach((group) => { + const groupValues = group.variables.map((variable) => values[variable]); + const noneAreSet = groupValues.every((value) => !valueExists(value)); + + if (group.mode === 'at_least_one' && noneAreSet) { + ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom }); + return; + } + + const someAreSet = !noneAreSet; + const someAreNotSet = groupValues.some((value) => !valueExists(value)); + + if (group.mode === 'none_or_all' && someAreSet && someAreNotSet) { + ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom }); + return; + } + }); + }); +}; + +const createValidatingZodSchemaFromEnvConfig = (envConfig) => { + const zodSchema = createZodSchemaFromEnvConfig(envConfig); + const validatingZodSchema = addVariableValidation(zodSchema, envConfig); + return validatingZodSchema; +}; + +module.exports = { + createEnvConfig, + createZodSchemaFromEnvConfig: createValidatingZodSchemaFromEnvConfig, +}; diff --git a/app/config/envConfig.ts b/app/config/envConfig.ts new file mode 100644 index 00000000..8eea61fa --- /dev/null +++ b/app/config/envConfig.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; + +type EnvConfig = { + variables: T; + groups: EnvVariableGroupConfiguration[]; +}; + +// Environment variable specific configuration +type EnvVariablesConfiguration = { + [key: string]: { + defaultRule: z.ZodTypeAny; + required?: boolean; + stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[]; + regex?: RegExp; + }; +}; + +// Configurations for a collection of environment variables +type EnvVariableGroupConfiguration = { + variables: (keyof T)[]; + mode: 'none_or_all' | 'at_least_one'; + errorMessage: string; +}; + +// Zod schema that is created based on a environment config +type ZodSchemaType = z.ZodObject<{ + [K in keyof TVariables]: TVariables[K]['defaultRule']; +}>; + +// Returns an environment config +const createEnvConfig = (props: { + variables: T; + groups: EnvConfig['groups']; +}) => { + return { groups: props.groups, variables: props.variables }; +}; + +// Creates a zod schema (z.Object({...})) based on an environment configuration +const createZodSchemaFromEnvConfig = (config: EnvConfig) => { + const zodSchema: { [key: string]: z.ZodTypeAny } = {}; + + Object.keys(config.variables).map((variable) => { + const key = variable as keyof TVariables; + const variableConfig = config.variables[key]; + const defaultRule = variableConfig.defaultRule; + zodSchema[variable] = defaultRule; + }); + + return z.object(zodSchema) as ZodSchemaType; +}; + +// Adds stage (build/lint/...) specific environment variable validation to a zod schema +const addVariableValidation = ( + schema: ZodSchemaType, + envConfig: EnvConfig, +) => { + const valueExists = (value: any | undefined) => value?.toString().trim().length > 0; + + return schema.superRefine((values, ctx) => { + const configKeys = Object.keys(envConfig.variables); + const currentStage = values.STAGE; // lint/build/production/... + + // Validate single environment variables one-by-one + configKeys.forEach((configKey) => { + const variableConfig = envConfig.variables[configKey]; + + // Check if env variable should be checked for this stage + const noStageApplies = variableConfig.stages?.every((stage) => stage !== currentStage) ?? false; + + if (noStageApplies) { + return; + } + + const value = values[configKey]; + const valueIsSet = valueExists(value); + + // Optional env variables may be omitted + if (!variableConfig.required && !valueIsSet) { + return; + } + + // Check that required env variables are set + if (!valueIsSet) { + ctx.addIssue({ + message: `Environment variable '${String(configKey)}' is required, but not set`, + code: z.ZodIssueCode.custom, + }); + return; + } + + // Check env variable against regex if configured + if (!variableConfig.regex) { + return; + } + + const matchesRegex = variableConfig.regex.test(value); + + if (!matchesRegex) { + ctx.addIssue({ + message: `Environment variable '${String(configKey)}' has an invalid format`, + code: z.ZodIssueCode.custom, + }); + } + }); + + // Validate env variable groups + envConfig.groups.forEach((group) => { + const groupValues = group.variables.map((variable) => values[variable]); + const noneAreSet = groupValues.every((value) => !valueExists(value)); + + if (group.mode === 'at_least_one' && noneAreSet) { + ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom }); + return; + } + + const someAreSet = !noneAreSet; + const someAreNotSet = groupValues.some((value) => !valueExists(value)); + + if (group.mode === 'none_or_all' && someAreSet && someAreNotSet) { + ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom }); + return; + } + }); + }); +}; + +const createValidatingZodSchemaFromEnvConfig = ( + envConfig: EnvConfig, +) => { + const zodSchema = createZodSchemaFromEnvConfig(envConfig); + const validatingZodSchema = addVariableValidation(zodSchema, envConfig); + return validatingZodSchema; +}; + +module.exports = { + createEnvConfig, + createZodSchemaFromEnvConfig: createValidatingZodSchemaFromEnvConfig, +}; diff --git a/app/config/helper.js b/app/config/helper.js deleted file mode 100644 index b7936aaa..00000000 --- a/app/config/helper.js +++ /dev/null @@ -1,8 +0,0 @@ -const someOrAllNotSet = (arr) => { - const someUndefined = arr.some((el) => el === ''); - const allDefined = arr.every((el) => el !== ''); - const allUndefined = arr.every((el) => el === ''); - return someUndefined && !allDefined && !allUndefined; -}; - -module.exports.someOrAllNotSet = someOrAllNotSet; diff --git a/app/config/server.js b/app/config/server.js index 621f0d94..027b4c77 100644 --- a/app/config/server.js +++ b/app/config/server.js @@ -1,241 +1,197 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - const { z } = require('zod'); -const { clientConfig } = require('./client'); -const { someOrAllNotSet } = require('./helper'); -const { cond } = require('lodash'); -const { env } = require('next-runtime-env'); +const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig'); if (typeof window !== 'undefined') { throw new Error('The server config should not be imported on the frontend!'); } -const RequiredBuildTimeEnv = z - .object({ - STAGE: z.enum(['development', 'test', 'build', 'production', 'lint']).default('development'), - NEXT_PUBLIC_BUILDTIMESTAMP: z - .string({ errorMap: () => ({ message: 'NEXT_PUBLIC_BUILDTIMESTAMP is not' }) }) - .default(''), - NEXT_PUBLIC_CI_COMMIT_SHA: z - .string({ errorMap: () => ({ message: 'NEXT_PUBLIC_CI_COMMIT_SHA is not' }) }) - .default(''), - }) - .superRefine((values, ctx) => { - const { STAGE } = values; - const requiredKeys = ['NEXT_PUBLIC_BUILDTIMESTAMP', 'NEXT_PUBLIC_CI_COMMIT_SHA']; - - const checkAndAddIssue = (condition, message, ctx) => { - if (condition && STAGE === 'build') { - ctx.addIssue({ - message: message, - code: z.ZodIssueCode.custom, - }); - } - }; - - const validateEnvVariables = (ctx) => { - requiredKeys.forEach((env) => - checkAndAddIssue( - values[env].trim().length === 0, - `Required build time environment variable ${env} is not set`, - ctx, - ), - ); - }; - - validateEnvVariables(ctx); - }); - -// Required at run-time -const RequiredRunTimeEnv = z - .object({ - DATABASE_URL: z.string().default(''), - POSTGRES_USER: z.string({ errorMap: () => ({ message: 'POSTGRES_USER must be set!' }) }).default(''), - POSTGRES_PASSWORD: z.string({ errorMap: () => ({ message: 'POSTGRES_PASSWORD must be set!' }) }).default(''), - NEXTAUTH_URL: z.string({ errorMap: () => ({ message: 'NEXTAUTH_URL must be set!' }) }).default(''), - NEXTAUTH_SECRET: z.string({ errorMap: () => ({ message: 'NEXTAUTH_SECRET must be set!' }) }).default(''), - STRAPI_TOKEN: z.string({ errorMap: () => ({ message: 'STRAPI_TOKEN must be set!' }) }).default(''), - HTTP_BASIC_AUTH: z.string().default(''), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - STAGE: z.enum(['development', 'test', 'build', 'production', 'lint']).default('development'), - REDIS_URL: z.string({ errorMap: () => ({ message: 'REDIS_URL must be set!' }) }).default(''), - NEWS_FEED_SYNC_SECRET: z - .string({ errorMap: () => ({ message: 'NEWS_FEED_SYNC_SECRET must be set!' }) }) - .default(''), - }) - .superRefine((values, ctx) => { - const { - DATABASE_URL, - NEXTAUTH_URL, - NEXTAUTH_SECRET, - STRAPI_TOKEN, - HTTP_BASIC_AUTH, - STAGE, - REDIS_URL, - NEWS_FEED_SYNC_SECRET, - } = values; - const required = [ - DATABASE_URL, - NEXTAUTH_URL, - NEXTAUTH_SECRET, - STRAPI_TOKEN, - HTTP_BASIC_AUTH, - REDIS_URL, - NEWS_FEED_SYNC_SECRET, - ]; - - const checkAndAddIssue = (condition, message, ctx) => { - if (condition && STAGE !== 'build' && STAGE !== 'lint') { - ctx.addIssue({ - message: message, - code: z.ZodIssueCode.custom, - }); - } - }; - - const validateEnvVariables = (required, DATABASE_URL, REDIS_URL, HTTP_BASIC_AUTH, ctx) => { - checkAndAddIssue( - required.some((el) => el === ''), - 'Not all required env variables are set!', - ctx, - ); - - checkAndAddIssue( - !/^(postgres|postgresql):\/\//.test(DATABASE_URL), - 'DB_URL is not a valid postgres connection string', - ctx, - ); - - checkAndAddIssue(!/^(redis):\/\//.test(REDIS_URL), 'REDIS_URL is not a valid postgres connection string', ctx); - - checkAndAddIssue( - !/(.+?):(.+?)$/.test(HTTP_BASIC_AUTH), - 'HTTP_BASIC_AUTH must be of the format username:password', - ctx, - ); - }; - - validateEnvVariables(required, DATABASE_URL, REDIS_URL, HTTP_BASIC_AUTH, ctx); - }); - -// Optional at run-time -const OptionalRunTimeEnv = z - .object({ - NEXTAUTH_AZURE_CLIENT_ID: z.string().default(''), - NEXTAUTH_AZURE_CLIENT_SECRET: z.string().default(''), - NEXTAUTH_AZURE_TENANT_ID: z.string().default(''), - NEXTAUTH_GITLAB_ID: z.string().default(''), - NEXTAUTH_GITLAB_SECRET: z.string().default(''), - NEXTAUTH_GITLAB_URL: z.string().default(''), - NEXTAUTH_CREDENTIALS_USERNAME: z.string().default(''), - NEXTAUTH_CREDENTIALS_PASSWORD: z.string().default(''), - VAPID_PRIVATE_KEY: z.string().default(''), - VAPID_ADMIN_EMAIL: z.string().default(''), - STRAPI_PUSH_NOTIFICATION_SECRET: z.string().default(''), - APP_INSIGHTS_SERVICE_NAME: z.string().default(''), - ANALYZE: z.boolean().default(false), - }) - .superRefine((values, ctx) => { - //Ignore the validation at build stage - if (process.env.STAGE === 'build' || process.env.STAGE === 'lint') return true; - //For each auth option we check if eiter all required fields or set or none (= method disabled) - const { - NEXTAUTH_AZURE_CLIENT_ID, - NEXTAUTH_AZURE_CLIENT_SECRET, - NEXTAUTH_AZURE_TENANT_ID, - NEXTAUTH_GITLAB_ID, - NEXTAUTH_GITLAB_SECRET, - NEXTAUTH_GITLAB_URL, - NEXTAUTH_CREDENTIALS_USERNAME, - NEXTAUTH_CREDENTIALS_PASSWORD, - VAPID_PRIVATE_KEY, - VAPID_ADMIN_EMAIL, - APP_INSIGHTS_SERVICE_NAME, - STRAPI_PUSH_NOTIFICATION_SECRET, - } = values; - const { - NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, - NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY, - NEXT_PUBLIC_VAPID_PUBLIC_KEY, - } = clientConfig; - - const azureAuth = [NEXTAUTH_AZURE_CLIENT_ID, NEXTAUTH_AZURE_CLIENT_SECRET, NEXTAUTH_AZURE_TENANT_ID]; - const gitlabAuth = [NEXTAUTH_GITLAB_ID, NEXTAUTH_GITLAB_SECRET, NEXTAUTH_GITLAB_URL]; - const credentialsAuth = [NEXTAUTH_CREDENTIALS_USERNAME, NEXTAUTH_CREDENTIALS_PASSWORD]; - const allAuthMethods = [...azureAuth, ...gitlabAuth, ...credentialsAuth]; - const notificationsEnv = [ - VAPID_PRIVATE_KEY, - VAPID_ADMIN_EMAIL, - NEXT_PUBLIC_VAPID_PUBLIC_KEY, - STRAPI_PUSH_NOTIFICATION_SECRET, - ]; - const appInsightsEnv = [NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY]; - - // Check that at least one auth method is enabled - if (!azureAuth.every((el) => el === '') && azureAuth.some((el) => el === '')) { - ctx.addIssue({ - message: 'All Azure variables are required to enable Azure SSO.', - code: z.ZodIssueCode.custom, - path: ['NEXTAUTH_AZURE_CLIENT_ID', 'NEXTAUTH_AZURE_CLIENT_SECRET', 'NEXTAUTH_AZURE_TENANT_ID'], - }); - } - - if (!gitlabAuth.every((el) => el === '') && gitlabAuth.some((el) => el === '')) { - ctx.addIssue({ - message: 'All GitLab variables are required to enable GitLab SSO.', - code: z.ZodIssueCode.custom, - path: ['NEXTAUTH_GITLAB_ID', 'NEXTAUTH_GITLAB_SECRET', 'NEXTAUTH_GITLAB_URL'], - }); - } - - if (!credentialsAuth.every((el) => el === '') && credentialsAuth.some((el) => el === '')) { - ctx.addIssue({ - message: 'All relevant variables are required to enable login via credentials', - code: z.ZodIssueCode.custom, - path: ['NEXTAUTH_CREDENTIALS_USERNAME', 'NEXTAUTH_CREDENTIALS_PASSWORD'], - }); - } - - if (allAuthMethods.every((el) => el === '')) { - ctx.addIssue({ - message: 'At least one authentication method must be enabled!', - code: z.ZodIssueCode.custom, - path: [], - }); - } - - // Some env variables require their counterpart in the UI be set and vice versa. - // If notifications enabled... - if (someOrAllNotSet(notificationsEnv)) { - ctx.addIssue({ - message: - 'Looks like the required environment variables for push-notifications are not set in the UI but in the server (or vice versa)', - code: z.ZodIssueCode.custom, - path: ['VAPID_PRIVATE_KEY', 'VAPID_ADMIN_EMAIL', 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', 'STRAPI_PUSH_NOTIFICATION_SECRET'], - }); - } - - // If ApplicationInsights enabled ... - if (!appInsightsEnv.every((el) => el === '') || APP_INSIGHTS_SERVICE_NAME) { - if (APP_INSIGHTS_SERVICE_NAME && appInsightsEnv.some((el) => el === '')) { - ctx.addIssue({ - message: - 'Looks like the required environment variables for ApplicationInsights are not set in the UI but in the server (or vice versa)', - code: z.ZodIssueCode.custom, - path: ['APP_INSIGHTS_SERVICE_NAME'], - }); - } - } - }); - -// If we run 'next build' the required runtime env variables can be empty, at run-time checks will be applied... -// NEXT_PUBLIC_* are checked in client.js -const requiredBuildEnv = RequiredBuildTimeEnv.parse(process.env); -const optionalRunTimeEnv = OptionalRunTimeEnv.parse(process.env); -const requiredRunTimeEnv = RequiredRunTimeEnv.parse(process.env); - -module.exports.serverConfig = { - ...requiredBuildEnv, - ...requiredRunTimeEnv, - ...optionalRunTimeEnv, -}; +const runtimeStages = ['development', 'test', 'production']; + +const serverEnvConfig = createEnvConfig({ + variables: { + // DO NOT remove the stage environment variable from this config + STAGE: { + defaultRule: z.enum(['development', 'test', 'build', 'production', 'lint']).default('development'), + required: true, + }, + + // Node + NODE_ENV: { + stages: runtimeStages, + defaultRule: z.enum(['development', 'production', 'test']).default('development'), + required: true, + }, + + // Version info + NEXT_PUBLIC_BUILDTIMESTAMP: { + stages: ['build'], + defaultRule: z.string().default(''), + required: true, + }, + NEXT_PUBLIC_CI_COMMIT_SHA: { + stages: ['build'], + defaultRule: z.string().default(''), + required: true, + }, + + // Database + POSTGRES_USER: { + stages: runtimeStages, + defaultRule: z.string().default(''), + required: true, + }, + POSTGRES_PASSWORD: { + stages: runtimeStages, + defaultRule: z.string().default(''), + required: true, + }, + DATABASE_URL: { + stages: runtimeStages, + defaultRule: z.string().default(''), + regex: /^(postgres|postgresql):\/\//, + required: true, + }, + + // Strapi + STRAPI_TOKEN: { + stages: runtimeStages, + defaultRule: z.string().default(''), + required: true, + }, + + // Nextauth configuration + NEXTAUTH_URL: { + stages: runtimeStages, + defaultRule: z.string().default(''), + required: true, + }, + NEXTAUTH_SECRET: { + stages: runtimeStages, + defaultRule: z.string().default(''), + required: true, + }, + HTTP_BASIC_AUTH: { + stages: runtimeStages, + defaultRule: z.string().default(''), + regex: /(.+?):(.+?)$/, + required: true, + }, + + // Redis + REDIS_URL: { + stages: runtimeStages, + defaultRule: z.string().default(''), + regex: /^(redis):\/\//, + required: true, + }, + NEWS_FEED_SYNC_SECRET: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Azure auth + NEXTAUTH_AZURE_CLIENT_ID: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXTAUTH_AZURE_CLIENT_SECRET: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXTAUTH_AZURE_TENANT_ID: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Gitlab auth + NEXTAUTH_GITLAB_ID: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXTAUTH_GITLAB_SECRET: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXTAUTH_GITLAB_URL: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Credential auth + NEXTAUTH_CREDENTIALS_USERNAME: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXTAUTH_CREDENTIALS_PASSWORD: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Push notifications + VAPID_PRIVATE_KEY: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + VAPID_ADMIN_EMAIL: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + STRAPI_PUSH_NOTIFICATION_SECRET: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Application Insights + APP_INSIGHTS_SERVICE_NAME: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + + // Bundle analyzer + ANALYZE: { + stages: runtimeStages, + defaultRule: z.boolean().optional().default(false), + }, + }, + groups: [ + { + variables: ['NEXTAUTH_AZURE_CLIENT_ID', 'NEXTAUTH_AZURE_CLIENT_SECRET', 'NEXTAUTH_AZURE_CLIENT_SECRET'], + mode: 'none_or_all', + errorMessage: 'Azure auth env variables not all defined', + }, + { + variables: ['NEXTAUTH_GITLAB_URL', 'NEXTAUTH_GITLAB_ID', 'NEXTAUTH_GITLAB_SECRET'], + mode: 'none_or_all', + errorMessage: 'Gitlab auth env variables not all defined', + }, + { + variables: ['NEXTAUTH_CREDENTIALS_USERNAME', 'NEXTAUTH_CREDENTIALS_PASSWORD'], + mode: 'none_or_all', + errorMessage: 'Credential auth env variables not all defined', + }, + { + variables: [ + 'NEXTAUTH_AZURE_CLIENT_ID', + 'NEXTAUTH_AZURE_CLIENT_SECRET', + 'NEXTAUTH_AZURE_CLIENT_SECRET', + 'NEXTAUTH_GITLAB_URL', + 'NEXTAUTH_GITLAB_ID', + 'NEXTAUTH_GITLAB_SECRET', + 'NEXTAUTH_CREDENTIALS_USERNAME', + 'NEXTAUTH_CREDENTIALS_PASSWORD', + ], + mode: 'at_least_one', + errorMessage: 'At least one type of authentication has to be set', + }, + ], +}); + +const schema = createZodSchemaFromEnvConfig(serverEnvConfig); +const serverConfig = schema.safeParse(process.env); + +if (!serverConfig.success) { + console.error(serverConfig.error.issues); + throw new Error('There is an error with the server environment variables'); +} + +module.exports.serverConfig = serverConfig.data; From 66d0e2545755d2531aaeeab4dd63e1b06b2c927a Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:42:30 +0200 Subject: [PATCH 2/6] fix: validate client side required env on runtime --- app/config/client.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/config/client.js b/app/config/client.js index 75efe971..7a15a4bf 100644 --- a/app/config/client.js +++ b/app/config/client.js @@ -14,16 +14,19 @@ const clientEnvConfig = createEnvConfig({ NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: { defaultRule: z.string().default(''), required: true, + stages: ['development', 'test', 'production'], }, NEXT_PUBLIC_STRAPI_ENDPOINT: { defaultRule: z.string().default(''), required: true, + stages: ['development', 'test', 'production'], }, // Push notifications NEXT_PUBLIC_VAPID_PUBLIC_KEY: { defaultRule: z.string().default(''), required: true, + stages: ['development', 'test', 'production'], }, // Application insights From d8c91ca08a7e6d1fed85c99648f9721f9d85dfac Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:03:40 +0200 Subject: [PATCH 3/6] fix: do not validate client env vars --- app/config/client.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/config/client.js b/app/config/client.js index 7a15a4bf..8f9c4bc4 100644 --- a/app/config/client.js +++ b/app/config/client.js @@ -14,19 +14,19 @@ const clientEnvConfig = createEnvConfig({ NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: { defaultRule: z.string().default(''), required: true, - stages: ['development', 'test', 'production'], + stages: [], }, NEXT_PUBLIC_STRAPI_ENDPOINT: { defaultRule: z.string().default(''), required: true, - stages: ['development', 'test', 'production'], + stages: [], }, // Push notifications NEXT_PUBLIC_VAPID_PUBLIC_KEY: { defaultRule: z.string().default(''), required: true, - stages: ['development', 'test', 'production'], + stages: [], }, // Application insights From 7b67bb324596e4fce17dc3c07a918e42da5f3acb Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:11:53 +0200 Subject: [PATCH 4/6] fix: introduce env config group stages --- app/config/client.js | 1 + app/config/envConfig.js | 13 ++++++++++--- app/config/envConfig.ts | 14 +++++++++++--- app/config/server.js | 4 ++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/config/client.js b/app/config/client.js index 8f9c4bc4..e7c15c36 100644 --- a/app/config/client.js +++ b/app/config/client.js @@ -49,6 +49,7 @@ const clientEnvConfig = createEnvConfig({ { variables: ['NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'], mode: 'none_or_all', + stages: ['development', 'test', 'production'], errorMessage: 'All Application Insights variables are required to enable Azure Application Insights', }, ], diff --git a/app/config/envConfig.js b/app/config/envConfig.js index 8add5209..e8f2e8e5 100644 --- a/app/config/envConfig.js +++ b/app/config/envConfig.js @@ -32,10 +32,10 @@ const addVariableValidation = (schema, envConfig) => { configKeys.forEach((configKey) => { const variableConfig = envConfig.variables[configKey]; - // Check if env variable should be checked for this stage - const noStageApplies = variableConfig.stages?.every((stage) => stage !== currentStage) ?? false; + // Check if env variable should be checked at this stage + const stagesApplies = variableConfig.stages?.some((stage) => stage === currentStage) ?? true; - if (noStageApplies) { + if (!stagesApplies) { return; } @@ -73,6 +73,13 @@ const addVariableValidation = (schema, envConfig) => { // Validate env variable groups envConfig.groups.forEach((group) => { + // Check if env variable group should be checked at this stage + const stageApplies = group.stages?.some((stage) => stage === currentStage) ?? true; + + if (!stageApplies) { + return; + } + const groupValues = group.variables.map((variable) => values[variable]); const noneAreSet = groupValues.every((value) => !valueExists(value)); diff --git a/app/config/envConfig.ts b/app/config/envConfig.ts index 8eea61fa..f11da502 100644 --- a/app/config/envConfig.ts +++ b/app/config/envConfig.ts @@ -19,6 +19,7 @@ type EnvVariablesConfiguration = { type EnvVariableGroupConfiguration = { variables: (keyof T)[]; mode: 'none_or_all' | 'at_least_one'; + stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[]; errorMessage: string; }; @@ -64,10 +65,10 @@ const addVariableValidation = ( configKeys.forEach((configKey) => { const variableConfig = envConfig.variables[configKey]; - // Check if env variable should be checked for this stage - const noStageApplies = variableConfig.stages?.every((stage) => stage !== currentStage) ?? false; + // Check if env variable should be checked at this stage + const stagesApplies = variableConfig.stages?.some((stage) => stage === currentStage) ?? true; - if (noStageApplies) { + if (!stagesApplies) { return; } @@ -105,6 +106,13 @@ const addVariableValidation = ( // Validate env variable groups envConfig.groups.forEach((group) => { + // Check if env variable group should be checked at this stage + const stageApplies = group.stages?.some((stage) => stage === currentStage) ?? true; + + if (!stageApplies) { + return; + } + const groupValues = group.variables.map((variable) => values[variable]); const noneAreSet = groupValues.every((value) => !valueExists(value)); diff --git a/app/config/server.js b/app/config/server.js index 027b4c77..9983e6dd 100644 --- a/app/config/server.js +++ b/app/config/server.js @@ -157,16 +157,19 @@ const serverEnvConfig = createEnvConfig({ { variables: ['NEXTAUTH_AZURE_CLIENT_ID', 'NEXTAUTH_AZURE_CLIENT_SECRET', 'NEXTAUTH_AZURE_CLIENT_SECRET'], mode: 'none_or_all', + stages: runtimeStages, errorMessage: 'Azure auth env variables not all defined', }, { variables: ['NEXTAUTH_GITLAB_URL', 'NEXTAUTH_GITLAB_ID', 'NEXTAUTH_GITLAB_SECRET'], mode: 'none_or_all', + stages: runtimeStages, errorMessage: 'Gitlab auth env variables not all defined', }, { variables: ['NEXTAUTH_CREDENTIALS_USERNAME', 'NEXTAUTH_CREDENTIALS_PASSWORD'], mode: 'none_or_all', + stages: runtimeStages, errorMessage: 'Credential auth env variables not all defined', }, { @@ -181,6 +184,7 @@ const serverEnvConfig = createEnvConfig({ 'NEXTAUTH_CREDENTIALS_PASSWORD', ], mode: 'at_least_one', + stages: runtimeStages, errorMessage: 'At least one type of authentication has to be set', }, ], From 89c64c0135249d3635da5eaeb3a723118439dc34 Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:34:35 +0200 Subject: [PATCH 5/6] fix: add missing allowed_origins env var --- app/config/client.js | 2 ++ app/config/envConfig.d.ts | 38 ++++++++++++++++++++++++++++++++++++++ app/config/envConfig.js | 5 +++-- app/config/envConfig.ts | 25 ++++++++++--------------- app/config/server.js | 11 +++++++++++ 5 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 app/config/envConfig.d.ts diff --git a/app/config/client.js b/app/config/client.js index e7c15c36..de54dcca 100644 --- a/app/config/client.js +++ b/app/config/client.js @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + const { z } = require('zod'); const { env } = require('next-runtime-env'); const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig'); diff --git a/app/config/envConfig.d.ts b/app/config/envConfig.d.ts new file mode 100644 index 00000000..5cbb6cf1 --- /dev/null +++ b/app/config/envConfig.d.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +type EnvConfig = { + variables: T; + groups: EnvVariableGroupConfiguration[]; +}; + +// Environment variable specific configuration +type EnvVariablesConfiguration = { + [key: string]: { + defaultRule: z.ZodTypeAny; + required?: boolean; + stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[]; + regex?: RegExp; + }; +}; + +// Configurations for a collection of environment variables +type EnvVariableGroupConfiguration = { + variables: (keyof T)[]; + mode: 'none_or_all' | 'at_least_one'; + stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[]; + errorMessage: string; +}; + +// Zod schema that is created based on a environment config +type ZodSchemaType = z.ZodObject<{ + [K in keyof TVariables]: TVariables[K]['defaultRule']; +}>; + +export declare const createEnvConfig: (props: { + variables: T; + groups: EnvConfig['groups']; +}) => EnvConfig; + +export declare const createZodSchemaFromEnvConfig: ( + envConfig: EnvConfig, +) => ReturnType['superRefine']>; diff --git a/app/config/envConfig.js b/app/config/envConfig.js index e8f2e8e5..a12476dc 100644 --- a/app/config/envConfig.js +++ b/app/config/envConfig.js @@ -1,4 +1,5 @@ -const { config } = require('process'); +/* eslint-disable @typescript-eslint/no-var-requires */ + const { z } = require('zod'); // Returns an environment config @@ -22,7 +23,7 @@ const createZodSchemaFromEnvConfig = (config) => { // Adds stage (build/lint/...) specific environment variable validation to a zod schema const addVariableValidation = (schema, envConfig) => { - const valueExists = (value) => value?.toString().trim().length > 0; + const valueExists = (value) => (value?.toString().trim().length ?? 0) > 0; return schema.superRefine((values, ctx) => { const configKeys = Object.keys(envConfig.variables); diff --git a/app/config/envConfig.ts b/app/config/envConfig.ts index f11da502..0d76b8a5 100644 --- a/app/config/envConfig.ts +++ b/app/config/envConfig.ts @@ -29,13 +29,21 @@ type ZodSchemaType = z.ZodObject<{ }>; // Returns an environment config -const createEnvConfig = (props: { +export const createEnvConfig = (props: { variables: T; groups: EnvConfig['groups']; }) => { return { groups: props.groups, variables: props.variables }; }; +export const createValidatingZodSchemaFromEnvConfig = ( + envConfig: EnvConfig, +): ReturnType['superRefine']> => { + const zodSchema = createZodSchemaFromEnvConfig(envConfig); + const validatingZodSchema = addVariableValidation(zodSchema, envConfig); + return validatingZodSchema; +}; + // Creates a zod schema (z.Object({...})) based on an environment configuration const createZodSchemaFromEnvConfig = (config: EnvConfig) => { const zodSchema: { [key: string]: z.ZodTypeAny } = {}; @@ -55,7 +63,7 @@ const addVariableValidation = ( schema: ZodSchemaType, envConfig: EnvConfig, ) => { - const valueExists = (value: any | undefined) => value?.toString().trim().length > 0; + const valueExists = (value: string | undefined) => (value?.toString().trim().length ?? 0) > 0; return schema.superRefine((values, ctx) => { const configKeys = Object.keys(envConfig.variables); @@ -131,16 +139,3 @@ const addVariableValidation = ( }); }); }; - -const createValidatingZodSchemaFromEnvConfig = ( - envConfig: EnvConfig, -) => { - const zodSchema = createZodSchemaFromEnvConfig(envConfig); - const validatingZodSchema = addVariableValidation(zodSchema, envConfig); - return validatingZodSchema; -}; - -module.exports = { - createEnvConfig, - createZodSchemaFromEnvConfig: createValidatingZodSchemaFromEnvConfig, -}; diff --git a/app/config/server.js b/app/config/server.js index 9983e6dd..064b9bf0 100644 --- a/app/config/server.js +++ b/app/config/server.js @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + const { z } = require('zod'); const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig'); @@ -77,6 +79,15 @@ const serverEnvConfig = createEnvConfig({ required: true, }, + // Next + ALLOWED_ORIGINS: { + stages: runtimeStages, + defaultRule: z + .string() + .transform((origins) => origins.split(',')) + .optional(), + }, + // Redis REDIS_URL: { stages: runtimeStages, From dbd1391755684a9b0de00e8ca8c32b3fba3b4152 Mon Sep 17 00:00:00 2001 From: "Fiedorowicz, Samuel" <56935464+expries@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:55:30 +0200 Subject: [PATCH 6/6] fix: check for server-client cross env var configurations --- app/config/server.js | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/config/server.js b/app/config/server.js index 064b9bf0..63ba6bf1 100644 --- a/app/config/server.js +++ b/app/config/server.js @@ -2,6 +2,7 @@ const { z } = require('zod'); const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig'); +const { clientConfig } = require('./client'); if (typeof window !== 'undefined') { throw new Error('The server config should not be imported on the frontend!'); @@ -151,12 +152,24 @@ const serverEnvConfig = createEnvConfig({ stages: runtimeStages, defaultRule: z.string().optional(), }, + NEXT_PUBLIC_VAPID_PUBLIC_KEY: { + stage: runtimeStages, + defaultRule: z.string().optional(), + }, // Application Insights APP_INSIGHTS_SERVICE_NAME: { stages: runtimeStages, defaultRule: z.string().optional(), }, + NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, + NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: { + stages: runtimeStages, + defaultRule: z.string().optional(), + }, // Bundle analyzer ANALYZE: { @@ -198,11 +211,39 @@ const serverEnvConfig = createEnvConfig({ stages: runtimeStages, errorMessage: 'At least one type of authentication has to be set', }, + { + variables: [ + 'VAPID_PRIVATE_KEY', + 'VAPID_ADMIN_EMAIL', + 'STRAPI_PUSH_NOTIFICATION_SECRET', + 'NEXT_PUBLIC_VAPID_PUBLIC_KEY', + ], + mode: 'none_or_all', + stages: runtimeStages, + errorMessage: + 'Looks like the required environment variables for push-notifications are not set in the UI but in the server (or vice versa)', + }, + { + variables: [ + 'APP_INSIGHTS_SERVICE_NAME', + 'NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', + 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY', + ], + mode: 'none_or_all', + stages: runtimeStages, + errorMessage: + 'Looks like the required environment variables for ApplicationInsights are not set in the UI but in the server (or vice versa)', + }, ], }); const schema = createZodSchemaFromEnvConfig(serverEnvConfig); -const serverConfig = schema.safeParse(process.env); +const serverConfig = schema.safeParse({ + ...process.env, + NEXT_PUBLIC_VAPID_PUBLIC_KEY: clientConfig.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: clientConfig.NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, + NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: clientConfig.NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY, +}); if (!serverConfig.success) { console.error(serverConfig.error.issues);