chore: harden deps, env validation, admin errors, and redis warnings
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m39s

Align nodemailer with Auth.js peers, bump patch deps, validate env on deploy builds, add admin error boundary, and warn when Redis is missing in production.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 20:19:05 +02:00
1 parent c865e6f699
commit c46dadeda4
10 files changed
+876 -461

No files matched your search

+7 -4
View File
@@ -50,7 +50,10 @@ DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# Optional SMTP (password reset / alert emails) # Preferred email provider (HTTP API). Used before SMTP when set.
RESEND_API_KEY=
# Optional SMTP fallback (password reset / alert emails)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
@@ -71,9 +74,9 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET= PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com PAYPAL_API=https://api-m.sandbox.paypal.com
# Optional Redis — enables shared caching for rate limiting and site settings, # Redis — strongly recommended in production (required for multi-instance).
# allowing horizontal scaling across multiple instances. Falls back to in-process # Shared rate limiting + site-settings cache. Without it, limits are in-process
# Maps when unset. # only and do not hold across restarts or multiple app instances.
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production, # Logging level (debug | info | warn | error). Defaults to 'info' in production,
+2 -1
View File
@@ -292,7 +292,8 @@ jobs:
pnpm prisma:generate pnpm prisma:generate
pnpm typecheck pnpm typecheck
pnpm test pnpm test
export SKIP_ENV_VALIDATION=1 # Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
pnpm build pnpm build
sudo chown -R www-data:www-data /var/www/atom-nexst/ sudo chown -R www-data:www-data /var/www/atom-nexst/
+5 -2
View File
@@ -7,12 +7,15 @@
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}", "src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
"src/app/**/actions.{ts,tsx}", "src/app/**/actions.{ts,tsx}",
"next.config.ts", "next.config.ts",
"src/middleware.ts" "src/proxy.ts",
"src/instrumentation.ts",
"sentry.{server,edge}.config.ts"
], ],
"project": ["src/**/*.{ts,tsx}"], "project": ["src/**/*.{ts,tsx}"],
"ignore": [ "ignore": [
"src/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}",
"scripts/**" "scripts/**"
], ],
"ignoreDependencies": ["@types/*", "knip"] "ignoreDependencies": ["@types/*", "knip"],
"ignoreBinaries": ["corepack"]
} }
+12 -11
View File
@@ -15,6 +15,7 @@
"biome:check": "biome check --write .", "biome:check": "biome check --write .",
"biome:lint": "biome lint .", "biome:lint": "biome lint .",
"biome:format": "biome format --write .", "biome:format": "biome format --write .",
"knip": "knip",
"analyze": "ANALYZE=true pnpm build", "analyze": "ANALYZE=true pnpm build",
"test": "vitest run", "test": "vitest run",
"db:migrate": "tsx scripts/apply-migrations.ts", "db:migrate": "tsx scripts/apply-migrations.ts",
@@ -27,9 +28,9 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@prisma/adapter-mariadb": "^7.8.0", "@prisma/adapter-mariadb": "^7.9.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.9.0",
"@sentry/nextjs": "^10.66.0", "@sentry/nextjs": "^10.67.0",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -49,13 +50,13 @@
"mysql2": "^3.23.0", "mysql2": "^3.23.0",
"next": "^16.2.11", "next": "^16.2.11",
"next-auth": "5.0.0-beta.31", "next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.2", "next-intl": "^4.13.3",
"next-view-transitions": "^0.3.5", "next-view-transitions": "^0.3.5",
"nodemailer": "^9.0.3", "nodemailer": "^7.0.13",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"react": "^19.2.0", "react": "^19.2.8",
"react-dom": "^19.2.0", "react-dom": "^19.2.8",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"resend": "^6.17.2", "resend": "^6.17.2",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
@@ -73,16 +74,16 @@
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^7.0.12",
"@types/react": "^19.2.0", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.3",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"knip": "^6.27.0", "knip": "^6.27.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.19", "postcss": "^8.5.19",
"prisma": "^7.8.0", "prisma": "^7.9.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.7.0", "typescript": "^5.7.0",
+752 -437
View File
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { LayoutDashboard, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
/**
* Admin route-segment error boundary. Renders inside the admin layout shell.
*/
export default function AdminErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("pages.error");
const tNav = useTranslations("admin.nav");
useEffect(() => {
console.error(error);
Sentry.captureException(error);
}, [error]);
return (
<div
className="rounded-xl border p-8 max-w-lg mx-auto mt-8 text-center"
style={{
borderColor: "var(--admin-border, rgba(0,0,0,0.08))",
background: "var(--admin-surface, #fff)",
}}
role="alert"
>
<p
className="text-sm font-bold uppercase tracking-wider mb-2"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("code")}
</p>
<h1 className="text-xl font-extrabold mb-2">{t("title")}</h1>
<p className="text-sm mb-1" style={{ color: "var(--admin-muted, #6b7280)" }}>
{t("subtitle")}
</p>
<p className="text-sm mb-6" style={{ color: "var(--admin-muted, #6b7280)" }}>
{t("body")}
</p>
<div className="flex flex-wrap gap-3 justify-center">
<button type="button" className="btn btn-primary" onClick={() => reset()}>
<RefreshCw className="size-4" aria-hidden />
{t("tryAgain")}
</button>
<Link className="btn btn-outline" href="/admin">
<LayoutDashboard className="size-4" aria-hidden />
{tNav("dashboard")}
</Link>
</div>
{error.digest ? (
<p
className="mt-6 text-xs font-mono"
style={{ color: "var(--admin-muted, #9ca3af)" }}
>
{t("reference", { digest: error.digest })}
</p>
) : null}
</div>
);
}
+4 -3
View File
@@ -69,7 +69,8 @@ const schema = z.object({
PAYPAL_CLIENT_ID: z.string().optional(), PAYPAL_CLIENT_ID: z.string().optional(),
PAYPAL_SECRET: z.string().optional(), PAYPAL_SECRET: z.string().optional(),
PAYPAL_API: z.string().url().optional(), PAYPAL_API: z.string().url().optional(),
// Optional Redis — enables shared caching for rate limiting and site settings. // Redis — strongly recommended in production (required for multi-instance).
// Without it, rate limits / shared caches are in-process only.
REDIS_URL: z.string().optional(), REDIS_URL: z.string().optional(),
// Logging level. // Logging level.
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
@@ -95,8 +96,8 @@ const schema = z.object({
type Env = z.infer<typeof schema>; type Env = z.infer<typeof schema>;
// SKIP_ENV_VALIDATION lets tooling (typecheck, tests that don't touch the DB) // SKIP_ENV_VALIDATION is for tooling only (vitest). Production deploy must NOT
// import modules transitively without a populated .env. // set this — builds should validate AUTH_SECRET, DATABASE_URL, etc.
export const env: Env = process.env.SKIP_ENV_VALIDATION export const env: Env = process.env.SKIP_ENV_VALIDATION
? (process.env as unknown as Env) ? (process.env as unknown as Env)
: schema.parse(process.env); : schema.parse(process.env);
+2 -1
View File
@@ -13,7 +13,8 @@ describe("production deploy workflow", () => {
expect(workflow).not.toMatch(/rm\s+-rf\s+\.next(?:\s|$)/); expect(workflow).not.toMatch(/rm\s+-rf\s+\.next(?:\s|$)/);
expect(workflow).toContain("rm -rf .output dist .next/types .next/dev"); expect(workflow).toContain("rm -rf .output dist .next/types .next/dev");
expect(workflow).toContain("pnpm install --frozen-lockfile"); expect(workflow).toContain("pnpm install --frozen-lockfile");
expect(workflow).toContain("SKIP_ENV_VALIDATION=1"); // Production builds must validate env (AUTH_SECRET, DATABASE_URL, …).
expect(workflow).not.toContain("SKIP_ENV_VALIDATION=1");
}); });
it("reclaims ownership before git reset so www-data files can be overwritten", () => { it("reclaims ownership before git reset so www-data files can be overwritten", () => {
+7
View File
@@ -13,6 +13,7 @@ const CLEANUP_INTERVAL_MS = 300_000;
const MAX_BUCKETS = 10_000; const MAX_BUCKETS = 10_000;
let lastCleanup = Date.now(); let lastCleanup = Date.now();
let redisFailWarned = false;
function cleanup(): void { function cleanup(): void {
const now = Date.now(); const now = Date.now();
@@ -54,6 +55,12 @@ export async function rateLimit(
return { ok: true, retryAfter: 0 }; return { ok: true, retryAfter: 0 };
} catch { } catch {
// Redis unavailable — fall through to in-memory // Redis unavailable — fall through to in-memory
if (process.env.NODE_ENV === "production" && !redisFailWarned) {
redisFailWarned = true;
console.error(
"[rate-limit] Redis error — falling back to in-process buckets. Limits are not shared across instances until Redis recovers.",
);
}
} }
} }
+16 -2
View File
@@ -2,11 +2,25 @@ import "server-only";
import Redis from "ioredis"; import Redis from "ioredis";
const globalForRedis = globalThis as unknown as { redis?: Redis | null }; const globalForRedis = globalThis as unknown as {
redis?: Redis | null;
redisMissingWarned?: boolean;
};
function createRedis(): Redis | null { function createRedis(): Redis | null {
const url = process.env.REDIS_URL; const url = process.env.REDIS_URL;
if (!url) return null; if (!url) {
if (
process.env.NODE_ENV === "production" &&
!globalForRedis.redisMissingWarned
) {
globalForRedis.redisMissingWarned = true;
console.error(
"[redis] REDIS_URL is unset in production. Rate limits and shared caches fall back to in-process memory and will not work correctly across multiple instances.",
);
}
return null;
}
try { try {
const client = new Redis(url, { const client = new Redis(url, {
maxRetriesPerRequest: 3, maxRetriesPerRequest: 3,