Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting

Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 07:28:01 +00:00
parent 7f37f111e8
commit df05ea5358
22 changed files with 822 additions and 20 deletions
+53
View File
@@ -0,0 +1,53 @@
import nodemailer from "nodemailer";
// Server-only. Deliberately its own SMTP transport, independent of
// Payload's (which now also sends mail — see Customers.ts's verification
// email) — the whole point of this module is alerting when something is
// broken, and if Payload itself is what's broken, routing the alert
// through it could mean the alert never arrives. Same Hostinger account
// either way (already proven working via Diun's update notifications),
// just a second, separate connection to it.
const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
// Fire-and-forget by design — callers should not await this in a way that
// blocks or fails the actual error response the customer sees. Wrap
// everything in its own try/catch so a broken mail relay never becomes a
// second, worse failure on top of the one being reported.
export function sendCriticalAlert(subject: string, details: Record<string, unknown>): void {
transport
.sendMail({
from: '"einfach produktiv Alerts" <admin@mk360.de>',
to: "admin@mk360.de",
subject: `[einfach produktiv] ${subject}`,
text: JSON.stringify(details, null, 2),
})
.catch((err) => {
console.error("sendCriticalAlert: failed to send alert email", err);
});
}
// The *initial* verification email (on registration) is sent by Payload
// itself, via Customers.ts's own afterChange hook — that one fires
// automatically on create and needs no separate wiring. This one is only
// for the "erneut senden" resend path (app/api/account/resend-verification/
// route.ts), which updates the token via the customer's own session
// (app/lib/customerAuth.ts's resendVerificationEmail) but has no Payload
// hook to piggyback on for a plain update, so it sends directly instead —
// same Hostinger transport as the alert above, just a different template.
export async function sendVerificationEmail(to: string, firstName: string, token: string): Promise<void> {
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
await transport.sendMail({
from: '"einfach produktiv" <admin@mk360.de>',
to,
subject: "Bitte bestätige deine E-Mail-Adresse",
html: `<p>Hallo ${firstName},</p><p>bitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv:</p><p><a href="${url}">${url}</a></p><p>Der Link ist 24 Stunden gültig.</p>`,
});
}
+112 -4
View File
@@ -1,5 +1,7 @@
import { cookies } from "next/headers";
import { randomUUID } from "node:crypto";
import type { CartItem } from "./cart";
import { sendVerificationEmail } from "./alertAdmin";
// Server-only — imported by app/api/account/*/route.ts, app/api/checkout/
// route.ts, and the /checkout and /konto/* Server Components. Never touch
@@ -12,6 +14,13 @@ import type { CartItem } from "./cart";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
const SESSION_COOKIE = "ep_customer_token";
// Reused from the order-creation service call (see orderServer.ts) for
// the handful of customer-collection operations that legitimately have no
// customer session of their own yet — the email-verification link click
// (cold, from an email client) being the main one. Same trust level
// ("this app's own backend acting on its own behalf"), so a third secret
// felt like unnecessary sprawl rather than added security.
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
async function resolveTenantId(): Promise<number | null> {
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
@@ -27,6 +36,7 @@ export type CustomerSummary = {
firstName: string;
lastName: string;
email: string;
emailVerified: boolean;
};
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
@@ -62,8 +72,10 @@ export async function loginCustomer(input: { email: string; password: string }):
});
if (!res.ok) return { ok: false, reason: "E-Mail-Adresse oder Passwort ist falsch." };
const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } } =
await res.json();
const data: {
token: string;
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean };
} = await res.json();
return {
ok: true,
token: data.token,
@@ -73,6 +85,7 @@ export async function loginCustomer(input: { email: string; password: string }):
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
},
};
}
@@ -83,8 +96,9 @@ export async function getCustomerFromToken(token: string): Promise<CustomerSumma
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } | null } =
await res.json();
const data: {
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean } | null;
} = await res.json();
if (!data.user) return null;
return {
id: data.user.id,
@@ -92,6 +106,7 @@ export async function getCustomerFromToken(token: string): Promise<CustomerSumma
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
};
}
@@ -113,6 +128,7 @@ type PayloadCustomerMe = {
firstName: string;
lastName: string;
email: string;
emailVerified: boolean;
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
@@ -138,6 +154,7 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
emailVerified: u.emailVerified,
deliveryMethod: u.deliveryMethod,
street: u.street,
packstationNumber: u.packstationNumber,
@@ -193,6 +210,61 @@ export async function changeCustomerPassword(
return { ok: true };
}
// Called from app/api/account/verify-email/route.ts — no customer session
// exists at this point (cold click from an email client), so this
// authenticates as the service instead (see SERVICE_SECRET above).
export async function verifyEmailByToken(token: string): Promise<boolean> {
const params = new URLSearchParams({ "where[emailVerificationToken][equals]": token, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
headers: { "x-order-service-secret": SERVICE_SECRET },
cache: "no-store",
});
if (!res.ok) return false;
const data: { docs?: { id: number; emailVerificationExpires: string | null }[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return false;
if (doc.emailVerificationExpires && new Date(doc.emailVerificationExpires).getTime() < Date.now()) return false;
const patchRes = await fetch(`${PAYLOAD_URL}/api/customers/${doc.id}`, {
method: "PATCH",
headers: { "x-order-service-secret": SERVICE_SECRET, "Content-Type": "application/json" },
body: JSON.stringify({ emailVerified: true }),
});
return patchRes.ok;
}
// Called by an already-logged-in customer (app/api/account/resend-
// verification/route.ts) — updates the token via their own session (self-
// update access, see Customers.ts), then sends the mail directly (no
// Payload afterChange hook to piggyback on for a plain update — that hook
// only fires on create, see Customers.ts's own comment).
export async function resendVerificationEmail(session: { token: string; customer: CustomerSummary }): Promise<boolean> {
const newToken = randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
const res = await fetch(`${PAYLOAD_URL}/api/customers/${session.customer.id}`, {
method: "PATCH",
headers: { Authorization: `JWT ${session.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ emailVerificationToken: newToken, emailVerificationExpires: expires }),
});
if (!res.ok) return false;
await sendVerificationEmail(session.customer.email, session.customer.firstName, newToken);
return true;
}
// Self-service GDPR deletion (app/api/account/delete/route.ts) — password
// re-verification happens there via loginCustomer() before this is ever
// called. orders.customer is ON DELETE SET NULL (see the Payload
// migration) — past orders keep their own name/address/items snapshot for
// tax-retention purposes (§147 AO / GDPR Art. 17(3)(b)), only the account
// itself disappears.
export async function deleteCustomerAccount(token: string, customerId: number): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "DELETE",
headers: { Authorization: `JWT ${token}` },
});
return res.ok;
}
export async function getServerCart(token: string): Promise<CartItem[]> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
@@ -223,8 +295,21 @@ export const ORDER_STATUS_LABEL: Record<string, string> = {
processing: "In Bearbeitung",
shipped: "Versandt",
delivered: "Zugestellt",
cancelled: "Storniert",
return_requested: "Rücksendung angefragt",
returned: "Zurückgesendet",
};
// Which self-service action is available given the order's current
// status — mirrors CUSTOMER_ALLOWED_TRANSITIONS in Orders.ts exactly
// (that hook is the real security boundary; this is just so the UI can
// decide which button, if any, to show).
export function customerOrderAction(status: string): "cancel" | "request-return" | null {
if (status === "received") return "cancel";
if (status === "shipped" || status === "delivered") return "request-return";
return null;
}
export type CustomerOrder = {
orderNumber: string;
createdAt: string;
@@ -257,6 +342,7 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
}
export type CustomerOrderDetail = CustomerOrder & {
id: number;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
@@ -298,6 +384,28 @@ export async function getCustomerOrderDetail(token: string, customerId: number,
return { ...doc, itemCount: doc.items.length };
}
// Called from app/api/account/orders/[orderNumber]/route.ts. Security
// lives in Orders.ts's beforeChange hook (only `status` can change, and
// only via an allowed transition) — this is just the authenticated call;
// a request the hook rejects comes back as a non-ok response here.
export async function requestOrderStatusChange(
token: string,
orderId: number,
action: "cancel" | "request-return",
): Promise<{ ok: true } | { ok: false; reason: string }> {
const status = action === "cancel" ? "cancelled" : "return_requested";
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
return { ok: false, reason: data?.errors?.[0]?.message ?? "Aktion war nicht möglich." };
}
return { ok: true };
}
// Cookie helpers — Next.js's async cookies() API (Next 15+), usable in
// Route Handlers (read/write) and Server Components (read-only).
export async function setSessionCookie(token: string) {
+43
View File
@@ -0,0 +1,43 @@
// Server-only, in-memory sliding-window limiter — deliberately no Redis:
// this app runs as a single Coolify container, so a plain in-process Map
// is sufficient and needs zero new infra. Counters reset on redeploy/
// restart (acceptable — an attacker gets a few extra free attempts right
// after a deploy, not a meaningful window) and won't share state if this
// ever scales to multiple instances; revisit with a shared store then.
//
// Complements, doesn't replace, Payload's own built-in per-account login
// lockout (Customers collection, maxLoginAttempts: 5 / lockTime: 10min,
// Payload defaults) — that stops brute-forcing one account, this stops an
// IP hammering registration or spraying attempts across many accounts.
const attempts = new Map<string, number[]>();
// Prevents unbounded growth from IPs that hit a route once and never
// return — without this, `attempts` would grow forever on a low-traffic
// site that nonetheless gets scanned/crawled periodically.
const MAX_TRACKED_KEYS = 10_000;
export function checkRateLimit(key: string, { limit, windowMs }: { limit: number; windowMs: number }): boolean {
const now = Date.now();
const windowStart = now - windowMs;
const timestamps = (attempts.get(key) ?? []).filter((t) => t > windowStart);
if (timestamps.length >= limit) {
attempts.set(key, timestamps);
return false;
}
timestamps.push(now);
if (attempts.size >= MAX_TRACKED_KEYS && !attempts.has(key)) {
attempts.clear();
}
attempts.set(key, timestamps);
return true;
}
// Caddy sits in front of this app and sets X-Forwarded-For — falls back to
// a constant key (effectively a single shared bucket) if that's ever
// missing, e.g. local dev, rather than disabling rate limiting outright.
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() || "unknown";
}