Files
moyosapp_beta.0.0.3.3_beta1/src/lib/env.ts
2026-01-17 22:06:30 +02:00

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,
});
}