Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate env vars with Zod #2362

Draft
wants to merge 9 commits into
base: miho-strict-null-checks
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions waspc/data/Generator/templates/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ COPY --from=server-builder /app/node_modules ./node_modules
# Copying the SDK because 'validate-env.mjs' executes independent of the bundle
# and references the 'wasp' package.
COPY --from=server-builder /app/.wasp/out/sdk .wasp/out/sdk
# Copying 'server/node_modules' because 'validate-env.mjs' executes independent
# of the bundle and references the dotenv package.
# Copying 'server/node_modules' because we require dotenv package to
# load environment variables
# TODO: replace dotenv with native Node.js environment variable loading
COPY --from=server-builder /app/.wasp/build/server/node_modules .wasp/build/server/node_modules
COPY --from=server-builder /app/.wasp/build/server/bundle .wasp/build/server/bundle
COPY --from=server-builder /app/.wasp/build/server/package*.json .wasp/build/server/
COPY --from=server-builder /app/.wasp/build/server/scripts .wasp/build/server/scripts
COPY db/ .wasp/build/db/
EXPOSE ${PORT}
WORKDIR /app/.wasp/build/server
Expand Down
4 changes: 2 additions & 2 deletions waspc/data/Generator/templates/sdk/wasp/client/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{{={= =}=}}
import { stripTrailingSlash } from '../universal/url.js'
import { env } from './env.js'

const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || '{= defaultServerUrl =}';
const apiUrl = stripTrailingSlash(env.REACT_APP_API_URL)

// PUBLIC API
export type ClientConfig = {
Expand Down
14 changes: 14 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/client/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{{={= =}=}}
import * as z from 'zod'

import { ensureEnvSchema } from '../env/index.js'

const clientEnvSchema = z.object({
REACT_APP_API_URL: z
.string({
required_error: 'REACT_APP_API_URL is required',
})
.default('{= defaultServerUrl =}')
})

export const env = ensureEnvSchema(import.meta.env, clientEnvSchema)
3 changes: 3 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export type Route = { method: HttpMethod; path: string }

// PUBLIC API
export { config, ClientConfig } from './config'

// PUBLIC API
export { env } from './env.js'
29 changes: 29 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/env/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as z from 'zod'

const redColor = '\x1b[31m'

export function ensureEnvSchema<Schema extends z.ZodTypeAny>(
data: unknown,
schema: Schema
): z.infer<Schema> {
try {
return schema.parse(data)
} catch (e) {
// TODO: figure out how to output the error message in a better way
if (e instanceof z.ZodError) {
console.error()
console.error(redColor, '╔═════════════════════════════╗');
console.error(redColor, '║ Env vars validation failed ║');
console.error(redColor, '╚═════════════════════════════╝');
console.error()
for (const error of e.errors) {
console.error(redColor, `- ${error.message}`)
}
console.error()
console.error(redColor, '═══════════════════════════════');
throw new Error('Error parsing environment variables')
} else {
throw e
}
}
}
7 changes: 7 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/prettier.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Used for internal Wasp development only, not copied to generated app.
module.exports = {
trailingComma: 'es5',
tabWidth: 2,
semi: false,
singleQuote: true,
}
15 changes: 0 additions & 15 deletions waspc/data/Generator/templates/sdk/wasp/server/auth/oauth/env.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import { OAuth2Provider, OAuth2ProviderWithPKCE } from "arctic";

export function defineProvider<
OAuthClient extends OAuth2Provider | OAuth2ProviderWithPKCE,
Env extends Record<string, string>
OAuthClient extends OAuth2Provider | OAuth2ProviderWithPKCE
>({
id,
displayName,
env,
oAuthClient,
}: {
id: string;
displayName: string;
env: Env;
oAuthClient: OAuthClient;
}) {
return {
id,
displayName,
env,
oAuthClient,
};
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
{{={= =}=}}
import { Discord } from "arctic";
import { Discord } from 'arctic';

import { defineProvider } from "../provider.js";
import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";
import { defineProvider } from '../provider.js';
import { getRedirectUriForCallback } from '../redirect.js';
import { env } from '../../../env.js';

const id = "{= providerId =}";
const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"],
displayName
);
const id = '{= providerId =}';
const displayName = '{= displayName =}';

const oAuthClient = new Discord(
env.DISCORD_CLIENT_ID,
Expand All @@ -23,6 +18,5 @@ const oAuthClient = new Discord(
export const discord = defineProvider({
id,
displayName,
env,
oAuthClient,
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
{{={= =}=}}
import { GitHub } from "arctic";
import { GitHub } from 'arctic';

import { ensureEnvVarsForProvider } from "../env.js";
import { defineProvider } from "../provider.js";
import { defineProvider } from '../provider.js';
import { env } from '../../../env.js';

const id = "{= providerId =}";
const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"],
displayName
);
const id = '{= providerId =}';
const displayName = '{= displayName =}';

const oAuthClient = new GitHub(
env.GITHUB_CLIENT_ID,
Expand All @@ -21,6 +16,5 @@ const oAuthClient = new GitHub(
export const github = defineProvider({
id,
displayName,
env,
oAuthClient,
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
{{={= =}=}}
import { Google } from "arctic";
import { Google } from 'arctic';

import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";
import { defineProvider } from "../provider.js";
import { getRedirectUriForCallback } from '../redirect.js';
import { defineProvider } from '../provider.js';
import { env } from '../../../env.js';

const id = "{= providerId =}";
const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
displayName,
);
const id = '{= providerId =}';
const displayName = '{= displayName =}';

const oAuthClient = new Google(
env.GOOGLE_CLIENT_ID,
Expand All @@ -23,6 +18,5 @@ const oAuthClient = new Google(
export const google = defineProvider({
id,
displayName,
env,
oAuthClient,
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
{{={= =}=}}
import { Keycloak } from "arctic";
import { Keycloak } from 'arctic';

import { ensureEnvVarsForProvider } from "../env.js";
import { getRedirectUriForCallback } from "../redirect.js";
import { defineProvider } from "../provider.js";
import { getRedirectUriForCallback } from '../redirect.js';
import { defineProvider } from '../provider.js';
import { env } from '../../../env.js';

const id = "{= providerId =}";
const displayName = "{= displayName =}";

const env = ensureEnvVarsForProvider(
["KEYCLOAK_REALM_URL", "KEYCLOAK_CLIENT_ID", "KEYCLOAK_CLIENT_SECRET"],
displayName,
);
const id = '{= providerId =}';
const displayName = '{= displayName =}';

const oAuthClient = new Keycloak(
env.KEYCLOAK_REALM_URL,
Expand All @@ -24,6 +19,5 @@ const oAuthClient = new Keycloak(
export const keycloak = defineProvider({
id,
displayName,
env,
oAuthClient,
});
111 changes: 32 additions & 79 deletions waspc/data/Generator/templates/sdk/wasp/server/config.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,47 @@
{{={= =}=}}
import merge from 'lodash.merge'
import { env } from './env.js'
import { stripTrailingSlash } from '../universal/url.js'

import { stripTrailingSlash } from "../universal/url.js";
type NodeEnv = typeof env.NODE_ENV

const nodeEnv = process.env.NODE_ENV ?? 'development'

// TODO:
// - Use dotenv library to consume env vars from a file.
// - Use convict library to define schema and validate env vars.
// https://codingsans.com/blog/node-config-best-practices

type BaseConfig = {
type Config = {
env: NodeEnv;
isDevelopment: boolean;
port: number;
databaseUrl: string;
frontendUrl: string;
serverUrl: string;
allowedCORSOrigins: string | string[];
{=# isAuthEnabled =}
auth: {
jwtSecret: string | undefined;
jwtSecret: string;
}
{=/ isAuthEnabled =}
}

type CommonConfig = BaseConfig & {
env: string;
isDevelopment: boolean;
port: number;
databaseUrl: string | undefined;
}
const frontendUrl = stripTrailingSlash(env.WASP_WEB_CLIENT_URL)
const serverUrl = stripTrailingSlash(env.WASP_SERVER_URL)

type EnvConfig = BaseConfig & {
frontendUrl: string;
serverUrl: string;
const allowedCORSOriginsPerEnv: Record<NodeEnv, string | string[]> = {
development: '*',
production: [frontendUrl]
}

type Config = CommonConfig & EnvConfig

const config: {
all: CommonConfig,
development: EnvConfig,
production: EnvConfig,
} = {
all: {
env: nodeEnv,
isDevelopment: nodeEnv === 'development',
port: process.env.PORT ? parseInt(process.env.PORT) : {= defaultServerPort =},
databaseUrl: process.env.{= databaseUrlEnvVarName =},
allowedCORSOrigins: [],
{=# isAuthEnabled =}
auth: {
jwtSecret: undefined
}
{=/ isAuthEnabled =}
},
development: getDevelopmentConfig(),
production: getProductionConfig(),
}

const resolvedConfig: Config = merge(config.all, config[nodeEnv])
// PUBLIC API
export default resolvedConfig

function getDevelopmentConfig(): EnvConfig {
const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL ?? '{= defaultClientUrl =}');
const serverUrl = stripTrailingSlash(process.env.WASP_SERVER_URL ?? '{= defaultServerUrl =}');
return {
// @ts-ignore
frontendUrl,
// @ts-ignore
serverUrl,
allowedCORSOrigins: '*',
{=# isAuthEnabled =}
auth: {
jwtSecret: 'DEVJWTSECRET'
}
{=/ isAuthEnabled =}
const allowedCORSOrigins = allowedCORSOriginsPerEnv[env.NODE_ENV]

const config: Config = {
frontendUrl,
serverUrl,
allowedCORSOrigins,
env: env.NODE_ENV,
isDevelopment: env.NODE_ENV === 'development',
port: env.PORT,
databaseUrl: env.{= databaseUrlEnvVarName =},
{=# isAuthEnabled =}
auth: {
jwtSecret: env.JWT_SECRET
}
{=/ isAuthEnabled =}
}

function getProductionConfig(): EnvConfig {
const frontendUrl = stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL);
const serverUrl = stripTrailingSlash(process.env.WASP_SERVER_URL);
return {
// @ts-ignore
frontendUrl,
// @ts-ignore
serverUrl,
// @ts-ignore
allowedCORSOrigins: [frontendUrl],
{=# isAuthEnabled =}
auth: {
jwtSecret: process.env.JWT_SECRET
}
{=/ isAuthEnabled =}
}
}
// PUBLIC API
export default config
Loading
Loading