diff --git a/next.config.ts b/next.config.ts index 2104451..aee8047 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,49 @@ import type { NextConfig } from 'next'; +const isProd = process.env.NODE_ENV === 'production'; + +/** + * Security headers applied to every response. Per audit-pass-#3 finding: + * the previous config emitted no CSP, X-Frame-Options, HSTS, or + * X-Content-Type-Options — the app was open to clickjacking + MIME + * sniffing. + * + * CSP notes: + * - 'unsafe-inline' on style-src is required by Tailwind's runtime + * style injection and Radix; revisit when Tailwind v4 ships a + * nonce story. + * - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for + * HMR. Production drops it. + * - connect-src allows ws/wss for Socket.IO and https: for outgoing + * fetches; tighten in prod via per-port branding URLs once we move + * the s3 image references into a known allowlist. + * - img-src https: is wide because port branding pulls from + * s3.portnimara.com plus per-port image URLs configured at runtime. + */ +const csp = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' ws: wss: https:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", +].join('; '); + +const securityHeaders = [ + { key: 'Content-Security-Policy', value: csp }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' }, + ...(isProd + ? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }] + : []), +]; + const nextConfig: NextConfig = { output: 'standalone', serverExternalPackages: [ @@ -24,6 +68,14 @@ const nextConfig: NextConfig = { // process.cwd() requires the file to be traced explicitly. '/api/v1/document-templates/**': ['./assets/eoi-template.pdf'], }, + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; diff --git a/src/lib/queue/scheduler.ts b/src/lib/queue/scheduler.ts index 4445e43..ec45a33 100644 --- a/src/lib/queue/scheduler.ts +++ b/src/lib/queue/scheduler.ts @@ -59,6 +59,8 @@ export async function registerRecurringJobs(): Promise { { 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) { diff --git a/src/lib/queue/workers/maintenance.ts b/src/lib/queue/workers/maintenance.ts index 6520149..f52d7bb 100644 --- a/src/lib/queue/workers/maintenance.ts +++ b/src/lib/queue/workers/maintenance.ts @@ -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'); }