fix(ops): security headers (CSP / XFO / HSTS / etc) + website_submissions retention

Two audit-pass-#3 prod-readiness gaps.

Security headers
  next.config.ts now emits CSP, X-Frame-Options=DENY,
  X-Content-Type-Options=nosniff, Referrer-Policy, Permissions-Policy
  on every response, plus HSTS in production. CSP allows the small
  set of inline-style/inline-script + unsafe-eval (dev-only) needed
  by Tailwind, Radix, and Next dev HMR; img-src/connect-src kept
  reasonably wide for s3.portnimara.com branding + Socket.IO. Verified
  via curl -I that headers ship and that the dashboard route still
  serves correctly.

website_submissions retention
  Adds 'website-submissions-retention' case to the maintenance worker
  with a 180-day window and schedules it at 07:00 daily. Raw inquiry
  payloads include reCAPTCHA + IP + UA metadata; keeping them
  indefinitely was a privacy + storage gap that audit-pass-#3 flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 15:16:47 +02:00
parent 8690352c56
commit f10334683d
3 changed files with 76 additions and 0 deletions

View File

@@ -59,6 +59,8 @@ export async function registerRecurringJobs(): Promise<void> {
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
// Migration 0040 contract: error_events older than 90 days get pruned.
{ queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' },
// Raw website inquiry payloads — 180-day retention.
{ queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' },
];
for (const job of recurring) {

View File

@@ -8,6 +8,7 @@ import { formSubmissions } from '@/lib/db/schema/documents';
import { gdprExports } from '@/lib/db/schema/gdpr';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { errorEvents } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { logger } from '@/lib/logger';
import { getStorageBackend } from '@/lib/storage';
import { QUEUE_CONFIGS } from '@/lib/queue';
@@ -17,6 +18,10 @@ const AI_USAGE_RETENTION_DAYS = 90;
/** error_events rows older than this are pruned. Migration 0040 declares
* this contract; the worker had no implementation until now. */
const ERROR_EVENTS_RETENTION_DAYS = 90;
/** Raw website inquiry payloads (website_submissions) — kept long enough
* to investigate "why didn't this lead reach the CRM" inbound questions
* but not indefinitely. 180d aligns with the typical sales cycle. */
const WEBSITE_SUBMISSIONS_RETENTION_DAYS = 180;
export const maintenanceWorker = new Worker(
'maintenance',
@@ -133,6 +138,23 @@ export const maintenanceWorker = new Worker(
);
break;
}
case 'website-submissions-retention': {
// Raw inquiry payloads from the marketing-site dual-write. Keep
// long enough to debug capture issues but not forever — these
// rows include reCAPTCHA + IP + UA metadata.
const cutoff = new Date(
Date.now() - WEBSITE_SUBMISSIONS_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const result = await db
.delete(websiteSubmissions)
.where(lt(websiteSubmissions.receivedAt, cutoff))
.returning({ id: websiteSubmissions.id });
logger.info(
{ deleted: result.length, retentionDays: WEBSITE_SUBMISSIONS_RETENTION_DAYS },
'Website submissions retention sweep complete',
);
break;
}
default:
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
}