diff --git a/next.config.js b/next.config.js index ee7c56a9..6e2bcea0 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,8 @@ -// GEM Build: 2026-06-06 10:54 UTC +// GEM Build: 2026-07-09 production-readiness hardening /** @type {import('next').NextConfig} */ const nextConfig = { + poweredByHeader: false, + experimental: { optimizePackageImports: [ '@radix-ui/react-accordion', @@ -44,20 +46,35 @@ const nextConfig = { }, async headers() { + const contentSecurityPolicy = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com https://vitals.vercel-insights.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' https:", + "frame-src 'self' https:", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", + ].join('; '); + return [ { source: '/:path*', headers: [ { key: 'X-DNS-Prefetch-Control', value: 'on' }, - { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()' }, - { - key: 'Content-Security-Policy-Report-Only', - value: "default-src 'self'; script-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://vitals.vercel-insights.com https://*.vercel-insights.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'", - }, + { key: 'Content-Security-Policy', value: contentSecurityPolicy }, + { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, + { key: 'Cross-Origin-Resource-Policy', value: 'same-site' }, ], }, { diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index d4145c0b..954fd0ff 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -5,21 +5,19 @@ import { getSession } from "@/lib/auth"; import { emitAuditLog } from "@/lib/audit"; import { getRequestContext, badRequest } from "@/lib/api/auth-helpers"; import { rateLimit, rateLimitedResponse } from "@/lib/api/rate-limit"; +import { sendMail } from "@/lib/mail/send"; const schema = z.object({ name: z.string().trim().min(2, "Name must be at least 2 characters").max(120), email: z.string().trim().toLowerCase().email("Invalid email address").max(254), message: z.string().trim().min(10, "Message must be at least 10 characters").max(5_000), subject: z.string().trim().max(200).optional(), - // Optional honeypot field — bots tend to fill every input. If populated we - // accept (200 OK) but silently drop the submission. website: z.string().optional(), }); export async function POST(req: NextRequest) { const { ipAddress, userAgent } = getRequestContext(req); - // Hard rate-limit per IP: 5 contact submissions / hour. const limit = rateLimit(ipAddress, { key: "contact:submit", windowMs: 60 * 60_000, @@ -41,75 +39,124 @@ export async function POST(req: NextRequest) { const { name, email, message, subject, website } = parsed.data; - // Honeypot — return 200 OK to avoid signaling rejection to the bot. + // Honeypot submissions receive a generic success response but are not stored. if (website && website.trim().length > 0) { return NextResponse.json({ ok: true }); } - // Capture the inbound contact as a SupportTicket only when the sender is - // already authenticated. Otherwise we never write to the database — the - // ticket model requires a real userId FK and we will not synthesize one - // (which previously caused FK violations and lost messages). const session = await getSession(); - let ticketId: string | null = null; + const normalizedSubject = subject?.trim() || `Contact from ${name}`; + const baseNotes = [ + `Website contact enquiry`, + `From: ${name} <${email}>`, + `Authenticated: ${session ? `yes (${session.email})` : "no"}`, + "", + message, + ].join("\n"); + + try { + // Persist every genuine public enquiry before reporting success. SupportBooking + // is intentionally used here because it accepts unauthenticated contacts and is + // already part of the production schema. Email is only a notification channel; + // it is not the system of record. + const submission = await db.supportBooking.create({ + data: { + userId: session?.userId, + name, + email, + subject: normalizedSubject, + status: "pending", + notes: `${baseNotes}\n\nNotification delivery: pending`, + }, + select: { id: true }, + }); + + let ticketId: string | null = null; + if (session) { + try { + const ticket = await db.supportTicket.create({ + data: { + userId: session.userId, + subject: normalizedSubject, + description: `From: ${name} <${email}>\n\n${message}`, + status: "open", + priority: "medium", + }, + select: { id: true }, + }); + ticketId = ticket.id; + } catch (error) { + console.error("[contact] failed to create authenticated support ticket", error); + } + } + + let delivery = "not_configured"; + const recipient = + process.env.ADMIN_EMAIL || + process.env.SUPPORT_EMAIL || + process.env.GEM_OWNER_EMAIL; + + if (recipient) { + try { + const result = await sendMail({ + to: recipient, + replyTo: email, + subject: `[GEM Contact] ${normalizedSubject}`, + text: `Submission ID: ${submission.id}\nName: ${name}\nEmail: ${email}\nAuth: ${session ? `yes (${session.email})` : "no"}\nIP: ${ipAddress}\n\n${message}`, + }); + delivery = result.sent + ? "sent" + : "reason" in result + ? result.reason + : "skipped"; + } catch (error) { + delivery = "failed"; + console.error("[contact] failed to send notification email", error); + } + } - if (session) { try { - const ticket = await db.supportTicket.create({ + await db.supportBooking.update({ + where: { id: submission.id }, data: { - userId: session.userId, - subject: subject?.trim() || `Contact from ${name}`, - description: `From: ${name} <${email}>\n\n${message}`, - status: "open", - priority: "medium", + notes: `${baseNotes}\n\nNotification delivery: ${delivery}`, }, - select: { id: true }, }); - ticketId = ticket.id; } catch (error) { - console.error("[contact] failed to persist ticket", error); - // non-fatal — fall through and still email + audit + console.error("[contact] failed to record notification status", error); } - } - await emitAuditLog({ - userId: session?.userId, - action: "admin_action", - resource: "contact_message", - resourceId: ticketId ?? undefined, - metadata: { - authenticated: Boolean(session), - email, - name, - subject: subject ?? null, - ticketId, - }, - ipAddress, - userAgent, - }); + await emitAuditLog({ + userId: session?.userId, + action: "admin_action", + resource: "contact_message", + resourceId: submission.id, + metadata: { + authenticated: Boolean(session), + email, + name, + subject: normalizedSubject, + submissionId: submission.id, + ticketId, + notificationDelivery: delivery, + }, + ipAddress, + userAgent, + }); - // Send notification email if SMTP is configured. We isolate the import in - // the conditional so deployments without nodemailer wired don't pay the - // bundle cost on the cold path. - if (process.env.SMTP_HOST && process.env.ADMIN_EMAIL) { - try { - const nodemailer = await import("nodemailer"); - const transporter = nodemailer.default.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT ?? 587), - auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, - }); - await transporter.sendMail({ - from: process.env.EMAIL_FROM ?? process.env.SMTP_USER, - to: process.env.ADMIN_EMAIL, - replyTo: email, - subject: `[GEM Contact] ${subject?.trim() || `Message from ${name}`}`, - text: `Name: ${name}\nEmail: ${email}\nAuth: ${session ? `yes (${session.email})` : "no"}\nIP: ${ipAddress}\n\n${message}`, - }); - } catch (error) { - console.error("[contact] failed to send notification email", error); - } + return NextResponse.json({ + ok: true, + submissionId: submission.id, + ticketId, + }); + } catch (error) { + console.error("[contact] failed to persist public enquiry", error); + return NextResponse.json( + { + ok: false, + error: "Your message could not be stored. Please use the published support email.", + }, + { status: 503 }, + ); } - - return NextResponse.json({ ok: true, ticketId }); } diff --git a/src/app/api/kyc/documents/route.ts b/src/app/api/kyc/documents/route.ts index 055598b9..7dd412c5 100644 --- a/src/app/api/kyc/documents/route.ts +++ b/src/app/api/kyc/documents/route.ts @@ -1,136 +1,37 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { db } from "@/lib/db"; -import { emitAuditLog } from "@/lib/audit"; -import { - requireSession, - getRequestContext, - badRequest, - serverError, -} from "@/lib/api/auth-helpers"; - -// Storage path is generated server-side from the application ID; the client -// only supplies metadata. This prevents an authenticated user from writing -// document rows that point at someone else's storage prefix. - -const ALLOWED_MIME = [ - "application/pdf", - "image/jpeg", - "image/png", - "image/webp", - "image/heic", -]; -const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB - -const createDocumentSchema = z.object({ - applicationId: z.string().min(1, "applicationId is required"), - documentType: z - .string() - .trim() - .min(2) - .max(64) - .regex(/^[a-z0-9_-]+$/i, "documentType must be alphanumeric/underscore"), - fileName: z - .string() - .trim() - .min(1) - .max(255) - // Block path traversal characters at the filename level. - .regex(/^[^/\\]+$/, "fileName must not contain path separators"), - fileSize: z - .number() - .int() - .nonnegative() - .max(MAX_FILE_SIZE, `File exceeds ${MAX_FILE_SIZE} bytes`) - .optional(), - mimeType: z.string().trim().min(1).max(127).optional(), -}); - -export async function POST(req: NextRequest) { +import { NextResponse } from "next/server"; +import { requireSession } from "@/lib/api/auth-helpers"; + +/** + * Production document intake is intentionally disabled until a complete private + * object-storage flow is available. The previous implementation created only a + * database metadata row and did not store, scan, or verify the actual file. That + * could make an application appear to contain documents when no document existed. + */ +export async function POST() { const gate = await requireSession(); - if (!gate.ok) return (gate as { ok: false; response: any }).response; - const session = gate.session; - const { ipAddress, userAgent } = getRequestContext(req); - - let body: unknown; - try { - body = await req.json(); - } catch { - return badRequest("Invalid JSON"); - } - - const parsed = createDocumentSchema.safeParse(body); - if (!parsed.success) { - return badRequest("Validation failed", parsed.error.flatten().fieldErrors); - } - - const { applicationId, documentType, fileName, fileSize, mimeType } = parsed.data; - const safeMime = mimeType && ALLOWED_MIME.includes(mimeType) - ? mimeType - : "application/octet-stream"; - - try { - // Ownership check: only allow attaching documents to the caller's own - // application, and only while the application is still editable. - const application = await db.kYCApplication.findFirst({ - where: { id: applicationId, userId: session.userId }, - select: { id: true, status: true }, - }); - if (!application) { - return NextResponse.json({ error: "Application not found" }, { status: 404 }); - } - if ( - application.status === "approved" || - application.status === "rejected" || - application.status === "expired" - ) { - return NextResponse.json( - { error: "Cannot attach documents to a finalized application" }, - { status: 409 }, - ); - } - - const document = await db.kycDocument.create({ - data: { - applicationId: application.id, - documentType, - fileName, - fileSize: fileSize ?? 0, - mimeType: safeMime, - // Server-controlled storage prefix scoped by application id. - storagePath: `kyc/${application.id}/${Date.now()}-${fileName}`, - status: "pending", - }, - }); - - // Bump the application status forward if it's still in early stages so - // the dashboard reflects upload progress. - if (application.status === "not_started" || application.status === "started") { - await db.kYCApplication.update({ - where: { id: application.id }, - data: { status: "documents_uploaded" }, - }); - } - - await emitAuditLog({ - userId: session.userId, - action: "document_upload", - resource: "kyc_document", - resourceId: document.id, - metadata: { - applicationId: application.id, - documentType, - fileName, - fileSize: fileSize ?? 0, - mimeType: safeMime, + if (!gate.ok) return (gate as { ok: false; response: NextResponse }).response; + + return NextResponse.json( + { + ok: false, + code: "SECURE_DOCUMENT_UPLOAD_NOT_ACTIVE", + error: + "Secure document upload is not active. Do not transmit identity or financial documents through this endpoint.", + requirements: [ + "private object storage", + "short-lived upload authorization", + "file-signature and size validation", + "malware scanning and quarantine", + "reviewer access controls and audit logging", + "retention and deletion enforcement", + ], + }, + { + status: 503, + headers: { + "Cache-Control": "no-store", + "Retry-After": "86400", }, - ipAddress, - userAgent, - }); - - return NextResponse.json({ ok: true, document }); - } catch (error) { - console.error("[POST /api/kyc/documents]", error); - return serverError(); - } + }, + ); } diff --git a/src/app/community-hub/layout.tsx b/src/app/community-hub/layout.tsx index de48d874..dcca27f2 100644 --- a/src/app/community-hub/layout.tsx +++ b/src/app/community-hub/layout.tsx @@ -1,16 +1,41 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; import { HubShell } from "@/components/hub/HubShell"; export const metadata: Metadata = { title: { - default: "Community Hub", - template: "%s | GEM Community Hub", + default: "Community Preview", + template: "%s | GEM Community Preview", }, description: - "GEM Community Hub — a private, verified network for partners, operators, investors, clients, and advisors executing across jurisdictions.", + "Preview of planned GEM Enterprise community capabilities. Sample profiles, organizations, events, statistics, and opportunities are demonstration data.", + robots: { + index: false, + follow: false, + nocache: true, + }, }; export default function CommunityHubLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + + {children} + + ); } diff --git a/src/app/intel/news/page.tsx b/src/app/intel/news/page.tsx index 15794921..3e68c09d 100644 --- a/src/app/intel/news/page.tsx +++ b/src/app/intel/news/page.tsx @@ -1,30 +1,14 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; -import { - ArrowRight, - ExternalLink, - Maximize2, - Minimize2, - RefreshCw, - Rss, - Shield, - Sparkles, -} from "lucide-react"; +import { AlertTriangle, ArrowRight, ExternalLink, Rss } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CuratedNewsFeed, type CuratedCategory, } from "@/components/intel/CuratedNewsFeed"; -// Base44-hosted fallback kept while the native feed is warming up. -const GEM_INTEL_FEED_URL = - "https://gem-intel-copy-769950ea.base44.app/news/category/crypto"; - const CATEGORIES: CuratedCategory[] = [ { label: "Crypto & Digital Assets", slug: "crypto" }, { label: "Cybersecurity", slug: "cybersecurity" }, @@ -35,279 +19,68 @@ const CATEGORIES: CuratedCategory[] = [ ]; export default function IntelNewsPage() { - const [expanded, setExpanded] = useState(false); - const [iframeKey, setIframeKey] = useState(0); - const [activeCategory, setActiveCategory] = useState("crypto"); - - const iframeSrc = `https://gem-intel-copy-769950ea.base44.app/news/category/${activeCategory}`; - return (
- {/* ══ HERO ══════════════════════════════════════════════════════════════ */} -
-
-
+
+
+
+
+ +
+
+
-
- - GEM INTEL · LIVE + CURATED - - - - Updating - -
-

- GEM Intel{" "} - News Feed + + SOURCED NEWS PREVIEW + +

+ GEM Intel News

-

- Continuous intelligence across crypto, cybersecurity, markets, - geopolitics, policy, and alternatives — ingested every three - hours and curated by the GEM analyst team. +

+ A category view of externally sourced articles. Feed status, source coverage, + retrieval time, and editorial review must be confirmed before this service is + described as continuous or analyst-curated.

-
- - -
+
- {/* ══ TABS: CURATED (native) + LIVE (iframe) ═══════════════════════════ */} -
- -
- - - - Curated - - - - Live (Base44) - - -
- Cron: every 3h · 17 sources -
-
- - {/* ── Curated (native DB) ─────────────────────────────────────── */} - - - - - {/* ── Live (embedded iframe) ──────────────────────────────────── */} - - {/* Category rail */} -
-
- - Categories - - {CATEGORIES.map((cat) => { - const isActive = cat.slug === activeCategory; - return ( - - ); - })} -
-
- -
-
-
-
- - GEM Intel —{" "} - { - CATEGORIES.find((c) => c.slug === activeCategory)?.label - } - - - Live Feed - -
-
- - - -
-
- -
-