chore(hardening): maintenance jobs, defense-in-depth, redis-backed public rate limit

- maintenance worker now expires GDPR export bundles (db row + MinIO object)
  on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on
  ai_usage_ledger; both jobs scheduled daily.
- portId scoping added to listClientRelationships and listClientExports
  (defense-in-depth — parent-resource gates already prevent cross-tenant
  reads, but service layer should enforce on its own).
- SELECT FOR UPDATE on parent client/company row inside add/update address
  transactions to serialize concurrent isPrimary toggles.
- public /interests + /residential-inquiries endpoints swap their
  in-memory ipHits maps for the redis sliding-window limiter via the
  new rateLimiters.publicForm config (5/hr/IP), so the cap survives
  restarts and is shared across worker processes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 01:52:41 +02:00
parent d9557edfc5
commit 43f68ca093
9 changed files with 252 additions and 44 deletions

View File

@@ -14,28 +14,23 @@ import {
import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
import { emitToRoom } from '@/lib/socket/server';
import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries';
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
/**
* Throws RateLimitError if the IP has exceeded the public-form quota.
* Backed by the Redis sliding-window limiter so the cap survives restarts
* and is shared across worker processes.
*/
async function gateRateLimit(ip: string): Promise<void> {
const result = await checkRateLimit(ip, rateLimiters.publicForm);
if (!result.allowed) {
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
throw new RateLimitError(retryAfter);
}
if (entry.count >= MAX_HITS) {
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
}
entry.count += 1;
}
/**
@@ -49,7 +44,7 @@ function checkRateLimit(ip: string): void {
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
checkRateLimit(ip);
await gateRateLimit(ip);
const body = await req.json();
const data = publicResidentialInquirySchema.parse(body);