feat: email verification + delete confirmation + smtp/cors/prod hardening

This commit is contained in:
2026-02-09 14:46:49 -06:00
parent 27cc7d159b
commit 9856317641
22 changed files with 896 additions and 58 deletions

View File

@@ -14,6 +14,7 @@ const Env = z.object({
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),
@@ -22,6 +23,22 @@ const Env = z.object({
AUTH_DISABLED: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
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 = {
@@ -30,6 +47,7 @@ const rawEnv = {
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",
@@ -37,13 +55,27 @@ const rawEnv = {
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
AUTH_DISABLED: process.env.AUTH_DISABLED,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
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) {
throw new Error("CORS_ORIGIN must be set in 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.");
@@ -57,6 +89,9 @@ if (parsed.NODE_ENV === "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.");
}
}
export const env = parsed;