300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
// src/lib/env.ts
|
|
import { z } from "zod";
|
|
|
|
const DEFAULT_ADMIN_PASSWORD = "TSD107AS#";
|
|
const DEFAULT_SESSION_SECRET =
|
|
"f3ERSdncczdgAruOxCKXqqd5O6KIn81VQDlZ0nOARteUQ9nq6YBJi6MqJgzqazVQ";
|
|
|
|
/**
|
|
* Helpers
|
|
*/
|
|
const emptyToUndefined = (val: unknown) =>
|
|
val === "" || val === undefined || val === null ? undefined : val;
|
|
|
|
const toBool = (val: unknown) => {
|
|
if (typeof val === "boolean") return val;
|
|
if (typeof val === "number") return val === 1;
|
|
if (typeof val === "string") {
|
|
const v = val.trim().toLowerCase();
|
|
if (v === "true" || v === "1" || v === "yes" || v === "y") return true;
|
|
if (v === "false" || v === "0" || v === "no" || v === "n") return false;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const EnvSchema = z.object({
|
|
// Environment
|
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
|
|
// App URLs
|
|
NEXT_PUBLIC_APP_URL: z.string().url().default("https://www.themoyos.co.za"),
|
|
APP_URL: z.string().url().optional(),
|
|
|
|
// Database
|
|
DATABASE_URL: z.string().url().optional(),
|
|
RUNTIME_DATABASE_URL: z.string().url().optional(),
|
|
DIRECT_URL: z.string().url().optional(),
|
|
SHADOW_DATABASE_URL: z.string().url().optional(),
|
|
|
|
// Supabase (public/client)
|
|
NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(),
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
|
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: z.string().optional(),
|
|
|
|
// Supabase (server/internal)
|
|
SUPABASE_URL: z.string().url().optional(),
|
|
SUPABASE_ANON_KEY: z.string().optional(),
|
|
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
|
|
|
|
// Auth / security
|
|
ADMIN_PASSWORD: z.string().min(8, "ADMIN_PASSWORD must be at least 8 characters").optional(),
|
|
SESSION_SECRET: z.string().min(32, "SESSION_SECRET must be at least 32 chars").optional(),
|
|
CSRF_SECRET: z.string().optional(),
|
|
|
|
// Redis
|
|
REDIS_URL: z.string().url().optional(),
|
|
|
|
// Email (Resend)
|
|
RESEND_API_KEY: z.string().optional(),
|
|
FROM_EMAIL: z.string().email().optional(),
|
|
RESEND_WEBHOOK_SECRET: z.string().optional(),
|
|
|
|
// Feature flags
|
|
ENABLE_NOTIFICATIONS: z.preprocess((v) => toBool(v) ?? v, z.coerce.boolean().default(true)),
|
|
ENABLE_ANALYTICS: z.preprocess((v) => toBool(v) ?? v, z.coerce.boolean().default(false)),
|
|
|
|
// Sentry
|
|
NEXT_PUBLIC_SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
|
SENTRY_AUTH_TOKEN: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
|
|
// Analytics
|
|
NEXT_PUBLIC_GA_MEASUREMENT_ID: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
|
|
// Weather
|
|
OPENWEATHER_API_KEY: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
|
|
// Ollama
|
|
OLLAMA_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
|
OLLAMA_MODEL: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
OLLAMA_TEMPERATURE: z.preprocess(emptyToUndefined, z.string().optional()),
|
|
|
|
// Operational toggles (Coolify / ops)
|
|
SKIP_ENV_VALIDATION: z.preprocess((v) => String(v ?? "0"), z.string().default("0")),
|
|
SKIP_HEALTH_DB_CHECK: z.preprocess((v) => String(v ?? "0"), z.string().default("0")),
|
|
SKIP_MIGRATIONS: z.preprocess((v) => String(v ?? "0"), z.string().default("0")),
|
|
});
|
|
|
|
const result = EnvSchema.safeParse({
|
|
NODE_ENV: process.env.NODE_ENV,
|
|
|
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
|
APP_URL: process.env.APP_URL,
|
|
|
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
RUNTIME_DATABASE_URL: process.env.RUNTIME_DATABASE_URL,
|
|
DIRECT_URL: process.env.DIRECT_URL,
|
|
SHADOW_DATABASE_URL: process.env.SHADOW_DATABASE_URL,
|
|
|
|
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY,
|
|
|
|
SUPABASE_URL: process.env.SUPABASE_URL,
|
|
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
|
|
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
|
|
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
|
SESSION_SECRET: process.env.SESSION_SECRET,
|
|
CSRF_SECRET: process.env.CSRF_SECRET,
|
|
|
|
REDIS_URL: process.env.REDIS_URL,
|
|
|
|
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
|
FROM_EMAIL: process.env.FROM_EMAIL,
|
|
RESEND_WEBHOOK_SECRET: process.env.RESEND_WEBHOOK_SECRET,
|
|
|
|
ENABLE_NOTIFICATIONS: process.env.ENABLE_NOTIFICATIONS,
|
|
ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS,
|
|
|
|
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
|
|
|
NEXT_PUBLIC_GA_MEASUREMENT_ID: process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID,
|
|
OPENWEATHER_API_KEY: process.env.OPENWEATHER_API_KEY,
|
|
|
|
OLLAMA_URL: process.env.OLLAMA_URL,
|
|
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
|
OLLAMA_TEMPERATURE: process.env.OLLAMA_TEMPERATURE,
|
|
|
|
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
|
SKIP_HEALTH_DB_CHECK: process.env.SKIP_HEALTH_DB_CHECK,
|
|
SKIP_MIGRATIONS: process.env.SKIP_MIGRATIONS,
|
|
});
|
|
|
|
if (!result.success) {
|
|
const errorMessages: string[] = [];
|
|
const formatted = result.error.format();
|
|
for (const [key, value] of Object.entries(formatted)) {
|
|
if (key === "_errors") continue;
|
|
const msgs = (value as any)?._errors;
|
|
if (msgs?.length) errorMessages.push(`${key}: ${msgs.join(", ")}`);
|
|
}
|
|
const errorMsg = `Environment variable validation failed: ${errorMessages.join(", ")}`;
|
|
// eslint-disable-next-line no-console
|
|
console.error("❌", errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const skipValidation = result.data.SKIP_ENV_VALIDATION === "1";
|
|
|
|
const resolvedAdminPassword =
|
|
result.data.ADMIN_PASSWORD ??
|
|
(result.data.NODE_ENV !== "production" || skipValidation
|
|
? DEFAULT_ADMIN_PASSWORD
|
|
: (() => {
|
|
throw new Error("ADMIN_PASSWORD required in production");
|
|
})());
|
|
|
|
const resolvedSessionSecret =
|
|
result.data.SESSION_SECRET ??
|
|
(result.data.NODE_ENV !== "production" || skipValidation
|
|
? DEFAULT_SESSION_SECRET
|
|
: (() => {
|
|
throw new Error("SESSION_SECRET required in production");
|
|
})());
|
|
|
|
const appUrlResolved = (result.data.APP_URL || result.data.NEXT_PUBLIC_APP_URL).trim();
|
|
|
|
export const ENV = {
|
|
NODE_ENV: result.data.NODE_ENV,
|
|
IS_PRODUCTION: result.data.NODE_ENV === "production",
|
|
IS_DEVELOPMENT: result.data.NODE_ENV === "development",
|
|
IS_TEST: result.data.NODE_ENV === "test",
|
|
|
|
// App URLs
|
|
APP_URL: appUrlResolved,
|
|
NEXT_PUBLIC_APP_URL: result.data.NEXT_PUBLIC_APP_URL,
|
|
|
|
// Database
|
|
DATABASE_URL: result.data.DATABASE_URL,
|
|
RUNTIME_DATABASE_URL: result.data.RUNTIME_DATABASE_URL ?? result.data.DATABASE_URL,
|
|
DIRECT_URL: result.data.DIRECT_URL,
|
|
SHADOW_DATABASE_URL: result.data.SHADOW_DATABASE_URL,
|
|
|
|
// Supabase
|
|
SUPABASE: {
|
|
// Browser/public
|
|
URL: result.data.NEXT_PUBLIC_SUPABASE_URL,
|
|
ANON_KEY: result.data.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
PUBLISHABLE_DEFAULT_KEY: result.data.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY,
|
|
|
|
// Server/internal (preferred for server components/routes)
|
|
SERVER_URL: result.data.SUPABASE_URL || result.data.NEXT_PUBLIC_SUPABASE_URL,
|
|
INTERNAL_ANON_KEY: result.data.SUPABASE_ANON_KEY || result.data.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
SERVICE_ROLE_KEY: result.data.SUPABASE_SERVICE_ROLE_KEY,
|
|
},
|
|
|
|
// Auth / security
|
|
ADMIN_PASSWORD: resolvedAdminPassword,
|
|
SESSION_SECRET: resolvedSessionSecret,
|
|
CSRF_SECRET: result.data.CSRF_SECRET,
|
|
|
|
// Redis
|
|
REDIS_URL: result.data.REDIS_URL,
|
|
|
|
// Email (Resend)
|
|
RESEND: {
|
|
API_KEY: result.data.RESEND_API_KEY,
|
|
FROM_EMAIL: result.data.FROM_EMAIL,
|
|
WEBHOOK_SECRET: result.data.RESEND_WEBHOOK_SECRET,
|
|
},
|
|
|
|
// Feature flags
|
|
ENABLE_NOTIFICATIONS: result.data.ENABLE_NOTIFICATIONS,
|
|
ENABLE_ANALYTICS: result.data.ENABLE_ANALYTICS,
|
|
|
|
// Sentry / analytics / extras
|
|
NEXT_PUBLIC_SENTRY_DSN: result.data.NEXT_PUBLIC_SENTRY_DSN,
|
|
SENTRY_AUTH_TOKEN: result.data.SENTRY_AUTH_TOKEN,
|
|
NEXT_PUBLIC_GA_MEASUREMENT_ID: result.data.NEXT_PUBLIC_GA_MEASUREMENT_ID,
|
|
OPENWEATHER_API_KEY: result.data.OPENWEATHER_API_KEY,
|
|
|
|
// Ollama
|
|
OLLAMA: {
|
|
URL: result.data.OLLAMA_URL,
|
|
MODEL: result.data.OLLAMA_MODEL,
|
|
TEMPERATURE: result.data.OLLAMA_TEMPERATURE ? parseFloat(result.data.OLLAMA_TEMPERATURE) : undefined,
|
|
},
|
|
|
|
// Ops toggles
|
|
SKIP_ENV_VALIDATION: result.data.SKIP_ENV_VALIDATION === "1",
|
|
SKIP_HEALTH_DB_CHECK: result.data.SKIP_HEALTH_DB_CHECK === "1",
|
|
SKIP_MIGRATIONS: result.data.SKIP_MIGRATIONS === "1",
|
|
} as const;
|
|
|
|
/**
|
|
* Runtime validation helper (prod strict, dev flexible).
|
|
* Call this at runtime (e.g., instrumentation.ts) — NOT during build time.
|
|
*/
|
|
export function validateEnvironment(): { isValid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
|
|
// Always required for auth to function
|
|
if (!ENV.ADMIN_PASSWORD) errors.push("ADMIN_PASSWORD is required");
|
|
if (!ENV.SESSION_SECRET) errors.push("SESSION_SECRET is required");
|
|
|
|
// Validate DB strings if present
|
|
const pgRx = /^postgres(ql)?:\/\//i;
|
|
if (ENV.DATABASE_URL && !pgRx.test(ENV.DATABASE_URL)) errors.push("DATABASE_URL must be a PostgreSQL connection string");
|
|
if (ENV.RUNTIME_DATABASE_URL && !pgRx.test(ENV.RUNTIME_DATABASE_URL)) errors.push("RUNTIME_DATABASE_URL must be a PostgreSQL connection string");
|
|
if (ENV.DIRECT_URL && !pgRx.test(ENV.DIRECT_URL)) errors.push("DIRECT_URL must be a PostgreSQL connection string");
|
|
if (ENV.SHADOW_DATABASE_URL && !pgRx.test(ENV.SHADOW_DATABASE_URL)) errors.push("SHADOW_DATABASE_URL must be a PostgreSQL connection string");
|
|
|
|
if (ENV.IS_PRODUCTION) {
|
|
if (!ENV.APP_URL) errors.push("APP_URL/NEXT_PUBLIC_APP_URL is required in production");
|
|
|
|
// Security
|
|
if (!process.env.SESSION_SECRET) errors.push("SESSION_SECRET must be set via env in production");
|
|
if (!process.env.ADMIN_PASSWORD) errors.push("ADMIN_PASSWORD must be set via env in production");
|
|
if (process.env.SESSION_SECRET === DEFAULT_SESSION_SECRET) errors.push("SESSION_SECRET cannot use development default in production");
|
|
if (process.env.ADMIN_PASSWORD === DEFAULT_ADMIN_PASSWORD) errors.push("ADMIN_PASSWORD cannot use development default in production");
|
|
if (!ENV.CSRF_SECRET) errors.push("CSRF_SECRET must be set in production");
|
|
|
|
// DB
|
|
if (!ENV.DATABASE_URL) errors.push("DATABASE_URL is required in production");
|
|
if (!ENV.RUNTIME_DATABASE_URL) errors.push("RUNTIME_DATABASE_URL is required in production");
|
|
|
|
// Supabase
|
|
if (!ENV.SUPABASE.URL) errors.push("NEXT_PUBLIC_SUPABASE_URL is required in production");
|
|
if (!ENV.SUPABASE.SERVER_URL) errors.push("SUPABASE_URL (internal) is required in production");
|
|
if (!ENV.SUPABASE.ANON_KEY) errors.push("NEXT_PUBLIC_SUPABASE_ANON_KEY is required in production");
|
|
if (!ENV.SUPABASE.SERVICE_ROLE_KEY) errors.push("SUPABASE_SERVICE_ROLE_KEY is required in production");
|
|
|
|
// Redis (you provided it; enforce it in prod for stability if your app uses it)
|
|
if (!ENV.REDIS_URL) errors.push("REDIS_URL is required in production");
|
|
|
|
// Email (if your RSVP flow sends email, enforce these)
|
|
if (!ENV.RESEND.API_KEY) errors.push("RESEND_API_KEY is required in production");
|
|
if (!ENV.RESEND.FROM_EMAIL) errors.push("FROM_EMAIL is required in production");
|
|
}
|
|
|
|
return { isValid: errors.length === 0, errors };
|
|
}
|
|
|
|
// Minimal, non-sensitive startup log (only in development)
|
|
if (process.env.NODE_ENV === "development") {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const { logger } = require("./logger");
|
|
logger.info("Environment configuration loaded", {
|
|
NODE_ENV: ENV.NODE_ENV,
|
|
HAS_DATABASE: !!ENV.DATABASE_URL,
|
|
HAS_REDIS: !!ENV.REDIS_URL,
|
|
NOTIFICATIONS_ENABLED: ENV.ENABLE_NOTIFICATIONS,
|
|
ANALYTICS_ENABLED: ENV.ENABLE_ANALYTICS,
|
|
SKIP_ENV_VALIDATION: ENV.SKIP_ENV_VALIDATION,
|
|
USING_DEFAULTS:
|
|
ENV.ADMIN_PASSWORD === DEFAULT_ADMIN_PASSWORD ||
|
|
ENV.SESSION_SECRET === DEFAULT_SESSION_SECRET,
|
|
});
|
|
} |