173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
import { z } from "zod";
|
|
|
|
const BoolFromEnv = z
|
|
.union([z.boolean(), z.string()])
|
|
.transform((val) => {
|
|
if (typeof val === "boolean") return val;
|
|
const normalized = val.trim().toLowerCase();
|
|
return normalized === "true" || normalized === "1";
|
|
});
|
|
|
|
function isLoopbackOrPrivateHostname(hostname: string): boolean {
|
|
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
if (!normalized) return true;
|
|
if (
|
|
normalized === "localhost" ||
|
|
normalized === "::1" ||
|
|
normalized === "0.0.0.0" ||
|
|
normalized.endsWith(".local")
|
|
) {
|
|
return true;
|
|
}
|
|
if (normalized.startsWith("127.")) return true;
|
|
if (normalized.startsWith("10.")) return true;
|
|
if (normalized.startsWith("192.168.")) return true;
|
|
if (normalized.startsWith("169.254.")) return true;
|
|
|
|
const parts = normalized.split(".");
|
|
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
|
|
const octets = parts.map((part) => Number(part));
|
|
if (octets.some((n) => n < 0 || n > 255)) return true;
|
|
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const Env = z.object({
|
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
PORT: z.coerce.number().int().positive().default(8080),
|
|
HOST: z.string().default("0.0.0.0"),
|
|
DATABASE_URL: z.string().min(1),
|
|
CORS_ORIGIN: z.string().optional(),
|
|
CORS_ORIGINS: z.string().optional(),
|
|
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
|
|
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
|
|
JWT_SECRET: z.string().min(32),
|
|
JWT_ISSUER: z.string().min(1).default("skymoney-api"),
|
|
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
|
|
COOKIE_SECRET: z.string().min(32),
|
|
COOKIE_DOMAIN: z.string().optional(),
|
|
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
|
|
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
|
|
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
|
|
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
|
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
|
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
|
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
|
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
|
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
|
|
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30),
|
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5),
|
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10),
|
|
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
|
|
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
|
|
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
|
|
UPDATE_NOTICE_BODY: z
|
|
.string()
|
|
.min(1)
|
|
.default("We shipped improvements and fixes. Please review the latest changes."),
|
|
SMTP_HOST: z.string().optional(),
|
|
SMTP_PORT: z.coerce.number().int().positive().optional(),
|
|
SMTP_REQUIRE_TLS: BoolFromEnv.default(true),
|
|
SMTP_TLS_REJECT_UNAUTHORIZED: BoolFromEnv.default(true),
|
|
SMTP_USER: z.string().optional(),
|
|
SMTP_PASS: z.string().optional(),
|
|
EMAIL_FROM: z.string().min(1).default("SkyMoney <noreply@skymoneybudget.com>"),
|
|
EMAIL_BOUNCE_FROM: z.string().optional(),
|
|
EMAIL_REPLY_TO: z.string().optional(),
|
|
});
|
|
|
|
const rawEnv = {
|
|
NODE_ENV: process.env.NODE_ENV,
|
|
PORT: process.env.PORT,
|
|
HOST: process.env.HOST,
|
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
|
CORS_ORIGINS: process.env.CORS_ORIGINS,
|
|
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
|
|
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
|
|
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
|
|
JWT_ISSUER: process.env.JWT_ISSUER,
|
|
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
|
|
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
|
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
|
|
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
|
|
BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
|
|
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
|
|
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
|
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
|
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
|
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
|
|
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
|
|
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
|
|
PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES,
|
|
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE,
|
|
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE,
|
|
APP_ORIGIN: process.env.APP_ORIGIN,
|
|
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
|
|
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
|
|
UPDATE_NOTICE_BODY: process.env.UPDATE_NOTICE_BODY,
|
|
SMTP_HOST: process.env.SMTP_HOST,
|
|
SMTP_PORT: process.env.SMTP_PORT,
|
|
SMTP_REQUIRE_TLS: process.env.SMTP_REQUIRE_TLS,
|
|
SMTP_TLS_REJECT_UNAUTHORIZED: process.env.SMTP_TLS_REJECT_UNAUTHORIZED,
|
|
SMTP_USER: process.env.SMTP_USER,
|
|
SMTP_PASS: process.env.SMTP_PASS,
|
|
EMAIL_FROM: process.env.EMAIL_FROM,
|
|
EMAIL_BOUNCE_FROM: process.env.EMAIL_BOUNCE_FROM,
|
|
EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO,
|
|
};
|
|
|
|
const parsed = Env.parse(rawEnv);
|
|
|
|
if (parsed.NODE_ENV === "production") {
|
|
if (!parsed.CORS_ORIGIN && !parsed.CORS_ORIGINS) {
|
|
throw new Error("CORS_ORIGIN or CORS_ORIGINS must be set in production.");
|
|
}
|
|
if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) {
|
|
throw new Error("AUTH_DISABLED cannot be enabled in production.");
|
|
}
|
|
if (parsed.SEED_DEFAULT_BUDGET) {
|
|
throw new Error("SEED_DEFAULT_BUDGET must be disabled in production.");
|
|
}
|
|
if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) {
|
|
throw new Error("JWT_SECRET must be set to a strong value in production.");
|
|
}
|
|
if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) {
|
|
throw new Error("COOKIE_SECRET must be set to a strong value in production.");
|
|
}
|
|
if (!parsed.APP_ORIGIN) {
|
|
throw new Error("APP_ORIGIN must be set in production.");
|
|
}
|
|
if (!parsed.APP_ORIGIN.startsWith("https://")) {
|
|
throw new Error("APP_ORIGIN must use https:// in production.");
|
|
}
|
|
let appOriginUrl: URL;
|
|
try {
|
|
appOriginUrl = new URL(parsed.APP_ORIGIN);
|
|
} catch {
|
|
throw new Error("APP_ORIGIN must be a valid URL in production.");
|
|
}
|
|
if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) {
|
|
throw new Error(
|
|
"APP_ORIGIN must not point to localhost or private network hosts in production."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) {
|
|
throw new Error(
|
|
"AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments."
|
|
);
|
|
}
|
|
|
|
if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) {
|
|
throw new Error(
|
|
"BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)."
|
|
);
|
|
}
|
|
|
|
export const env = parsed;
|