Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
df72a74
fix(trust): clearly label community hub preview data
support371 Jul 9, 2026
0a38caa
fix(trust): stop presenting static intelligence as live
support371 Jul 9, 2026
0b375b2
fix(services): remove unsupported guarantees and keep detail links ho…
support371 Jul 9, 2026
dcd741e
feat(services): add working service detail routes
support371 Jul 9, 2026
935c213
fix(contact): durably store every public enquiry before success
support371 Jul 9, 2026
5ea8db5
fix(kyc): fail closed until secure document storage is active
support371 Jul 9, 2026
6225268
fix(kyc): reject metadata-only document submissions in production
support371 Jul 9, 2026
e9ea641
fix(trust): remove unverified live-news and analyst claims
support371 Jul 9, 2026
4d334e6
fix(security): enforce baseline browser security policy
support371 Jul 9, 2026
5cc5f77
fix(services): correct property service image source
support371 Jul 9, 2026
b22f694
fix(home): replace unsupported operational claims with verified launc…
support371 Jul 9, 2026
afca989
feat(trust): add site-wide controlled-launch disclosure
support371 Jul 9, 2026
beba83a
feat(trust): show controlled-launch disclosure across public pages
support371 Jul 9, 2026
fb56487
fix(seo): include active store and service routes in sitemap
support371 Jul 9, 2026
4307cbd
fix(store): add truthful request-only catalogue presentation
support371 Jul 9, 2026
7cc5232
fix(store): present homepage products as request-only catalogue
support371 Jul 9, 2026
563a504
fix(store): remove unverified checkout and availability claims
support371 Jul 9, 2026
6cabacc
fix(store): label store as request-only catalogue
support371 Jul 9, 2026
e0108a5
fix(store): fail closed on legacy product checkout routes
support371 Jul 9, 2026
9c1db06
fix(store): make all storefront pages request-only and verification-led
support371 Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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' },
],
},
{
Expand Down
165 changes: 106 additions & 59 deletions src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });
}
Loading
Loading