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_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_PORT=587
SMTP_USER=
@@ -71,9 +74,9 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
# Optional Redis — enables shared caching for rate limiting and site settings,
# allowing horizontal scaling across multiple instances. Falls back to in-process
# Maps when unset.
# Redis — strongly recommended in production (required for multi-instance).
# Shared rate limiting + site-settings cache. Without it, limits are in-process
# only and do not hold across restarts or multiple app instances.
REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
+2 -1
View File
@@ -292,7 +292,8 @@ jobs:
pnpm prisma:generate
pnpm typecheck
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
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/**/actions.{ts,tsx}",
"next.config.ts",
"src/middleware.ts"
"src/proxy.ts",
"src/instrumentation.ts",
"sentry.{server,edge}.config.ts"
],
"project": ["src/**/*.{ts,tsx}"],
"ignore": [
"src/**/*.test.{ts,tsx}",
"scripts/**"
],
"ignoreDependencies": ["@types/*", "knip"]
"ignoreDependencies": ["@types/*", "knip"],
"ignoreBinaries": ["corepack"]
}
+12 -11
View File
@@ -15,6 +15,7 @@
"biome:check": "biome check --write .",
"biome:lint": "biome lint .",
"biome:format": "biome format --write .",
"knip": "knip",
"analyze": "ANALYZE=true pnpm build",
"test": "vitest run",
"db:migrate": "tsx scripts/apply-migrations.ts",
@@ -27,9 +28,9 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@sentry/nextjs": "^10.66.0",
"@prisma/adapter-mariadb": "^7.9.0",
"@prisma/client": "^7.9.0",
"@sentry/nextjs": "^10.67.0",
"@tanstack/react-virtual": "^3.14.6",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -49,13 +50,13 @@
"mysql2": "^3.23.0",
"next": "^16.2.11",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.2",
"next-intl": "^4.13.3",
"next-view-transitions": "^0.3.5",
"nodemailer": "^9.0.3",
"nodemailer": "^7.0.13",
"otplib": "^12.0.1",
"pino": "^10.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.81.0",
"resend": "^6.17.2",
"sanitize-html": "^2.17.6",
@@ -73,16 +74,16 @@
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/nodemailer": "^7.0.12",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/sanitize-html": "^2.16.1",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^16.4.0",
"knip": "^6.27.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.19",
"prisma": "^7.8.0",
"prisma": "^7.9.0",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"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_SECRET: z.string().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(),
// Logging level.
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
@@ -95,8 +96,8 @@ const schema = z.object({
type Env = z.infer<typeof schema>;
// SKIP_ENV_VALIDATION lets tooling (typecheck, tests that don't touch the DB)
// import modules transitively without a populated .env.
// SKIP_ENV_VALIDATION is for tooling only (vitest). Production deploy must NOT
// set this — builds should validate AUTH_SECRET, DATABASE_URL, etc.
export const env: Env = process.env.SKIP_ENV_VALIDATION
? (process.env as unknown as 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).toContain("rm -rf .output dist .next/types .next/dev");
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", () => {
+7
View File
@@ -13,6 +13,7 @@ const CLEANUP_INTERVAL_MS = 300_000;
const MAX_BUCKETS = 10_000;
let lastCleanup = Date.now();
let redisFailWarned = false;
function cleanup(): void {
const now = Date.now();
@@ -54,6 +55,12 @@ export async function rateLimit(
return { ok: true, retryAfter: 0 };
} catch {
// 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";
const globalForRedis = globalThis as unknown as { redis?: Redis | null };
const globalForRedis = globalThis as unknown as {
redis?: Redis | null;
redisMissingWarned?: boolean;
};
function createRedis(): Redis | null {
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 {
const client = new Redis(url, {
maxRetriesPerRequest: 3,