refactor: env validation typescript and common package (#1912)

This commit is contained in:
Meier Lukas
2025-01-14 19:03:38 +01:00
committed by GitHub
parent a03a01b964
commit 1fd3fd8dfb
36 changed files with 98 additions and 83 deletions

View File

@@ -1,13 +1,13 @@
// Importing env files here to validate on build
import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import "@homarr/auth/env";
import "@homarr/db/env";
import "@homarr/common/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
import "./src/env.ts";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");

View File

@@ -21,7 +21,7 @@ import type { AppRouter } from "@homarr/api";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env.mjs";
import { env } from "~/env";
const getWebSocketProtocol = () => {
// window is not defined on server side

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";

View File

@@ -28,7 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {

View File

@@ -9,7 +9,7 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";

View File

@@ -5,7 +5,7 @@ import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";

View File

@@ -5,7 +5,7 @@ import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { env } from "~/env.mjs";
import { env } from "~/env";
import { WidgetPreviewPageContent } from "./_content";
interface Props {

View File

@@ -1,6 +1,8 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
shared: {
PORT: z.coerce.number().default(3000),
@@ -13,7 +15,7 @@ export const env = createEnv({
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.number().optional(),
DOCKER_PORTS: z.string().optional(),
},
/**
* Specify your client-side environment variables schema here.
@@ -32,6 +34,5 @@ export const env = createEnv({
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});

View File

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
export const throwIfCredentialsDisabled = () => {
if (!env.AUTH_PROVIDERS.includes("credentials")) {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import * as env from "@homarr/auth/env.mjs";
import * as env from "@homarr/auth/env";
import { createId, eq } from "@homarr/db";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";

View File

@@ -24,7 +24,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],

View File

@@ -28,7 +28,7 @@ vi.mock("@homarr/auth", async () => {
});
// Mock the env module to return the credentials provider
vi.mock("@homarr/auth/env.mjs", () => {
vi.mock("@homarr/auth/env", () => {
return {
env: {
AUTH_PROVIDERS: ["credentials"],

View File

@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
import { env } from "./env.mjs";
import { env } from "./env";
import { createSignInEventHandler } from "./events";
import { createCredentialsConfiguration, createLdapConfiguration } from "./providers/credentials/credentials-provider";
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";

View File

@@ -1,10 +1,9 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
import { supportedAuthProviders } from "@homarr/definitions";
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
const authProvidersSchema = z
.string()
.min(1)
@@ -14,7 +13,7 @@ const authProvidersSchema = z
.toLowerCase()
.split(",")
.filter((provider) => {
if (supportedAuthProviders.includes(provider)) return true;
if (supportedAuthProviders.some((supportedProvider) => supportedProvider === provider)) return true;
else if (!provider)
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
@@ -23,41 +22,7 @@ const authProvidersSchema = z
)
.default("credentials");
const createDurationSchema = (defaultValue) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1];
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
const booleanSchema = z
.string()
.default("false")
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
const skipValidation = shouldSkipEnvValidation();
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
export const env = createEnv({
@@ -71,7 +36,7 @@ export const env = createEnv({
AUTH_OIDC_CLIENT_ID: z.string().min(1),
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),

View File

@@ -8,7 +8,7 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import { extractProfileName } from "./providers/oidc/oidc-provider";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {

View File

@@ -11,7 +11,7 @@
"./client": "./client.ts",
"./server": "./server.ts",
"./shared": "./shared.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",

View File

@@ -1,6 +1,6 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { env } from "../env.mjs";
import { env } from "../env";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page

View File

@@ -7,7 +7,7 @@ import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { env } from "../../../env.mjs";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
export const authorizeWithLdapCredentialsAsync = async (

View File

@@ -3,7 +3,7 @@ import { Client } from "ldapts";
import { objectEntries } from "@homarr/common";
import { env } from "../../env.mjs";
import { env } from "../../env";
export interface BindOptions {
distinguishedName: string;

View File

@@ -1,6 +1,6 @@
import type { Provider } from "next-auth/providers";
import { env } from "../env.mjs";
import { env } from "../env";
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
// During build this will be undefined, so we default to an empty array

View File

@@ -2,7 +2,7 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { env } from "../../env.mjs";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({

View File

@@ -9,7 +9,7 @@ import { createDb } from "@homarr/db/test";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
import * as ldapClient from "../credentials/ldap-client";
vi.mock("../../env.mjs", () => ({
vi.mock("../../env", () => ({
env: {
AUTH_LDAP_BIND_DN: "bind_dn",
AUTH_LDAP_BIND_PASSWORD: "bind_password",

View File

@@ -11,7 +11,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("../env.mjs", () => {
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",

View File

@@ -2,6 +2,8 @@ import { randomBytes } from "crypto";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "./src/env-validation";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
export const env = createEnv({
@@ -23,6 +25,5 @@ export const env = createEnv({
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});

View File

@@ -9,7 +9,8 @@
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./client": "./src/client.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts",
"./env-validation": "./src/env-validation.ts"
},
"typesVersions": {
"*": {
@@ -30,7 +31,8 @@
"dayjs": "^1.11.13",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,6 +1,6 @@
import crypto from "crypto";
import { env } from "../env.mjs";
import { env } from "../env";
const algorithm = "aes-256-cbc"; //Using AES encryption

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
const trueStrings = ["1", "yes", "t", "true"];
const falseStrings = ["0", "no", "f", "false"];
export const createBooleanSchema = (defaultValue: boolean) =>
z
.string()
.default(defaultValue.toString())
.transform((value, ctx) => {
const normalized = value.trim().toLowerCase();
if (trueStrings.includes(normalized)) return true;
if (falseStrings.includes(normalized)) return false;
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
});
export const createDurationSchema = (defaultValue: `${number}${"s" | "m" | "h" | "d"}`) =>
z
.string()
.regex(/^\d+[smhd]?$/)
.default(defaultValue)
.transform((duration) => {
const lastChar = duration[duration.length - 1] as "s" | "m" | "h" | "d";
if (!isNaN(Number(lastChar))) {
return Number(defaultValue);
}
const multipliers = {
s: 1,
m: 60,
h: 60 * 60,
d: 60 * 60 * 24,
};
const numberDuration = Number(duration.slice(0, -1));
const multiplier = multipliers[lastChar];
return numberDuration * multiplier;
});
export const shouldSkipEnvValidation = () =>
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint";

View File

@@ -1,4 +1,4 @@
import { env } from "@homarr/auth/env.mjs";
import { env } from "@homarr/auth/env";
import { NEVER } from "@homarr/cron-jobs-core/expressions";
import { db, eq, inArray } from "@homarr/db";
import { sessions, users } from "@homarr/db/schema";

View File

@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "mysql",

View File

@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import { env } from "../env.mjs";
import { env } from "../env";
export default {
dialect: "sqlite",

View File

@@ -7,7 +7,7 @@ import mysql from "mysql2";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
import * as sqliteSchema from "./schema/sqlite";

View File

@@ -1,12 +1,14 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
};
} as const;
const isDriver = (driver) => process.env.DB_DRIVER === driver;
const isDriver = (driver: (typeof drivers)[keyof typeof drivers]) => process.env.DB_DRIVER === driver;
const isUsingDbHost = Boolean(process.env.DB_HOST);
const onlyAllowUrl = isDriver(drivers.betterSqlite3);
const urlRequired = onlyAllowUrl || !isUsingDbHost;
@@ -55,6 +57,5 @@ export const env = createEnv({
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
},
skipValidation:
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
skipValidation: shouldSkipEnvValidation(),
});

View File

@@ -3,7 +3,7 @@ import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { seedDataAsync } from "../seed";

View File

@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env.mjs";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { seedDataAsync } from "../seed";

View File

@@ -11,7 +11,7 @@
"./test": "./test/index.ts",
"./queries": "./queries/index.ts",
"./validationSchemas": "./validationSchemas.ts",
"./env.mjs": "./env.mjs"
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",

3
pnpm-lock.yaml generated
View File

@@ -716,6 +716,9 @@ importers:
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
zod:
specifier: ^3.24.1
version: 3.24.1
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0