From 368e4260ba5e33194c45d387754700c3fdbe6c4b Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 28 Jun 2026 14:42:19 +0100 Subject: [PATCH] fix(prompt-production): resolve ci issues for auth and seo --- next.config.js | 16 ++- src/__tests__/production-readiness.test.ts | 16 +++ src/app/api/auth/forgot-password/route.ts | 67 +++++++++++++ src/app/cookie-policy/page.tsx | 9 ++ src/app/forgot-password/page.tsx | 107 +++++++++++++++++++++ src/app/robots.txt/route.ts | 30 ++++++ src/app/sitemap.xml/route.ts | 26 +++++ src/app/trust-center/page.tsx | 6 ++ src/components/Footer.tsx | 2 +- src/lib/siteRoutes.ts | 23 ++++- src/proxy.ts | 23 ++++- 11 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/production-readiness.test.ts create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/cookie-policy/page.tsx create mode 100644 src/app/forgot-password/page.tsx create mode 100644 src/app/robots.txt/route.ts create mode 100644 src/app/sitemap.xml/route.ts create mode 100644 src/app/trust-center/page.tsx diff --git a/next.config.js b/next.config.js index e7b07bf..68f624d 100644 --- a/next.config.js +++ b/next.config.js @@ -74,6 +74,18 @@ const nextConfig = { 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'", + }, ], }, { @@ -90,7 +102,9 @@ const nextConfig = { // Redirects async redirects() { - return []; + return [ + { source: '/blog', destination: '/resources', permanent: true }, + ]; }, // Rewrites diff --git a/src/__tests__/production-readiness.test.ts b/src/__tests__/production-readiness.test.ts new file mode 100644 index 0000000..df08ef0 --- /dev/null +++ b/src/__tests__/production-readiness.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; + +describe("production route hygiene", () => { + it("has cookie policy and trust center pages", () => { + expect(fs.existsSync("src/app/cookie-policy/page.tsx")).toBe(true); + expect(fs.existsSync("src/app/trust-center/page.tsx")).toBe(true); + }); + it("protects private community hub routes", () => { + const proxy = fs.readFileSync("src/proxy.ts", "utf8"); + for (const route of ["/community-hub/members","/community-hub/messages","/community-hub/requests","/community-hub/profile","/community-hub/settings","/community-hub/opportunities"]) expect(proxy).toContain(route); + }); + it("footer links to dedicated cookie policy", () => { + expect(fs.readFileSync("src/components/Footer.tsx", "utf8")).toContain('path: "/cookie-policy"'); + }); +}); diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..ee63dab --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { emitAuditLog } from "@/lib/audit"; +import { getRequestContext } from "@/lib/api/auth-helpers"; +import { rateLimit, rateLimitedResponse } from "@/lib/api/rate-limit"; + +const forgotPasswordSchema = z.object({ + email: z.string().trim().email("Enter a valid email address.").max(254), +}); + +const SAFE_RESPONSE = { + success: true, + message: "If an active GEM Enterprise account exists for that email, password reset instructions will be sent shortly.", +}; + +export async function POST(request: NextRequest) { + const { ipAddress, userAgent } = getRequestContext(request); + const limit = rateLimit(ipAddress, { + key: "auth:forgot-password", + windowMs: 15 * 60_000, + max: 5, + }); + + if (!limit.ok) { + await emitAuditLog({ + action: "failed_login", + resource: "auth", + metadata: { flow: "forgot_password", reason: "rate_limited" }, + ipAddress, + userAgent, + }); + return rateLimitedResponse(limit.retryAfterSeconds); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = forgotPasswordSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ); + } + + const email = parsed.data.email.toLowerCase(); + const user = await db.user.findUnique({ + where: { email }, + select: { id: true, email: true }, + }); + + await emitAuditLog({ + userId: user?.id, + action: "password_change", + resource: "auth", + metadata: { flow: "forgot_password", email, accepted: Boolean(user) }, + ipAddress, + userAgent, + }); + + return NextResponse.json(SAFE_RESPONSE); +} diff --git a/src/app/cookie-policy/page.tsx b/src/app/cookie-policy/page.tsx new file mode 100644 index 0000000..8be0240 --- /dev/null +++ b/src/app/cookie-policy/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; +export const metadata: Metadata = { title: "Cookie Policy", description: "GEM Enterprise cookie categories, purposes, and consent choices." }; +const rows = [ + ["Essential", "Authentication, session security, load balancing, and form integrity.", "Required; cannot be disabled."], + ["Security", "Abuse prevention, audit evidence, fraud detection, and protected-client access controls.", "Required for secured services."], + ["Preferences", "Remembering non-sensitive interface choices such as dismissed notices.", "Optional where implemented."], + ["Analytics", "Aggregate site performance and usage measurement through Vercel analytics when enabled.", "Optional/non-essential; deploy only where consent is honored."], +]; +export default function CookiePolicyPage(){return

Legal

Cookie Policy

GEM Enterprise uses limited cookies and similar technologies to operate a secure, KYC-gated platform. We do not use cookies to sell personal information.

{rows.map(([a,b,c])=>)}
CategoryPurposeConsent
{a}{b}{c}

Consent behavior

Essential and security cookies are set only as needed to provide the site and authenticated portal. If analytics or other non-essential cookies are enabled in a deployment, they must be blocked until the visitor provides consent and must support withdrawal by contacting privacy@gemcybersecurityassist.com or through any consent control presented in the interface.

Current session cookie

The authenticated portal uses the gem_session cookie with HttpOnly, Secure in production, and SameSite=Lax settings.

} diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..93cc5af --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; + +type FormState = "idle" | "loading" | "success" | "error" | "rate-limit"; + +function ForgotPasswordForm() { + const searchParams = useSearchParams(); + const isExpiredToken = searchParams.get("expired") === "1" || searchParams.get("state") === "expired-token"; + const [email, setEmail] = useState(""); + const [state, setState] = useState(isExpiredToken ? "error" : "idle"); + const [message, setMessage] = useState( + isExpiredToken ? "Your reset link has expired. Request a new secure link below." : "", + ); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setState("loading"); + setMessage(""); + + const response = await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = (await response.json().catch(() => ({}))) as { + error?: string; + message?: string; + retryAfterSeconds?: number; + }; + + if (response.status === 429) { + setState("rate-limit"); + setMessage(`Too many requests. Try again in ${data.retryAfterSeconds ?? 60} seconds.`); + return; + } + + if (!response.ok) { + setState("error"); + setMessage(data.error ?? "We could not process the request."); + return; + } + + setState("success"); + setMessage(data.message ?? "If an active account exists, reset instructions will be sent shortly."); + } + + return ( +
+
+

Account recovery

+

Forgot password

+

+ Enter your account email. For security, responses do not reveal whether an email is registered. +

+ +
+ + +
+ + {message ? ( +
+ {message} +
+ ) : null} + +

+ + Return to client login + +

+
+
+ ); +} + +export default function ForgotPasswordPage() { + return ( + Loading recovery form…}> + + + ); +} diff --git a/src/app/robots.txt/route.ts b/src/app/robots.txt/route.ts new file mode 100644 index 0000000..16c6aec --- /dev/null +++ b/src/app/robots.txt/route.ts @@ -0,0 +1,30 @@ +const DEFAULT_APP_URL = "https://www.gemcybersecurityassist.com"; + +const disallowedRoutes = [ + "/app/", + "/admin/", + "/account/", + "/billing/", + "/documents/", + "/messages/", + "/requests/", + "/community-hub/members", + "/community-hub/messages", + "/community-hub/requests", + "/community-hub/profile", + "/community-hub/settings", + "/community-hub/opportunities", +]; + +export function GET() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || DEFAULT_APP_URL; + const body = [ + "User-agent: *", + "Allow: /", + ...disallowedRoutes.map((route) => `Disallow: ${route}`), + `Sitemap: ${baseUrl}/sitemap.xml`, + "", + ].join("\n"); + + return new Response(body, { headers: { "content-type": "text/plain" } }); +} diff --git a/src/app/sitemap.xml/route.ts b/src/app/sitemap.xml/route.ts new file mode 100644 index 0000000..ee35da3 --- /dev/null +++ b/src/app/sitemap.xml/route.ts @@ -0,0 +1,26 @@ +const DEFAULT_APP_URL = "https://www.gemcybersecurityassist.com"; + +const routes = [ + "/", + "/services", + "/intel", + "/resources", + "/company", + "/about", + "/contact", + "/get-started", + "/eligibility/status", + "/privacy", + "/terms", + "/compliance-notice", + "/cookie-policy", + "/trust-center", +]; + +export function GET() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || DEFAULT_APP_URL; + const urls = routes.map((route) => `${baseUrl}${route}`).join(""); + const xml = `${urls}`; + + return new Response(xml, { headers: { "content-type": "application/xml" } }); +} diff --git a/src/app/trust-center/page.tsx b/src/app/trust-center/page.tsx new file mode 100644 index 0000000..6e6bc21 --- /dev/null +++ b/src/app/trust-center/page.tsx @@ -0,0 +1,6 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +export const metadata: Metadata = { title: "Trust Center", description: "Security, data protection, compliance mapping, disclosure, and due diligence for GEM Enterprise." }; +const mappings=["SOC 2 controls mapped; certification in progress where applicable.","ISO 27001 control alignment designed for future certification readiness.","NIST CSF functions mapped to identify, protect, detect, respond, and recover operations.","GDPR privacy principles supported for applicable data subject requests.","HIPAA safeguards considered for engagements involving regulated health information; GEM does not claim covered-entity status by default.","CMMC practices mapped for defense-supply-chain readiness; certification is not claimed unless separately evidenced."]; +export default function TrustCenterPage(){return

Trust Center

Security and compliance overview

GEM Enterprise is designed to align with enterprise security, privacy, evidence-retention, and vendor due-diligence expectations for qualified clients. Formal attestations are provided only when available and upon verified client request.

Defense-in-depth access controls, encrypted transport, protected session cookies, role-based authorization, audit logging, and KYC-gated workflows.Report suspected vulnerabilities to security@gemcybersecurityassist.com. Provide reproducible details and avoid accessing client data.Client data is handled under least-privilege access, retention controls, and documented operational review. Evidence records follow the platform retention model.Cloud hosting, email delivery, payments, analytics, and AI providers may be used only when configured for an engagement. The current subprocessor list is available upon verified client request.

Compliance mapping

    {mappings.map(m=>
  • {m}
  • )}

Vendor due diligence request

Qualified prospects and clients may request security questionnaires, architecture summaries, insurance evidence, and available attestations.

Request due diligence materials

Status page: placeholder pending connected uptime provider.

} +function Card({title,children}:{title:string;children:React.ReactNode}){return

{title}

{children}

} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index f97f6fb..20b0933 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -23,7 +23,7 @@ const legalLinks = [ { label: "Privacy Policy", path: "/privacy" }, { label: "Terms of Service", path: "/terms" }, { label: "Compliance Notice", path: "/compliance-notice" }, - { label: "Cookie Policy", path: "/privacy#cookies" }, + { label: "Cookie Policy", path: "/cookie-policy" }, ]; const clientAccessLinks = [ diff --git a/src/lib/siteRoutes.ts b/src/lib/siteRoutes.ts index 6b893c5..919c6e1 100644 --- a/src/lib/siteRoutes.ts +++ b/src/lib/siteRoutes.ts @@ -535,6 +535,28 @@ export const canonicalRoutes: SiteRoute[] = [ showInNav: false, showInFooter: true, }, + { + path: "/cookie-policy", + label: "Cookie Policy", + category: "compliance", + description: "Cookie categories, consent behavior, and privacy controls", + isPublic: true, + isCanonical: true, + menuGroup: "none", + owner: "legal", + showInFooter: true, + }, + { + path: "/trust-center", + label: "Trust Center", + category: "compliance", + description: "Security, data protection, responsible disclosure, and compliance mapping", + isPublic: true, + isCanonical: true, + menuGroup: "company", + owner: "legal", + showInFooter: true, + }, // PROTECTED APPLICATION { @@ -833,7 +855,6 @@ export const legacyRedirects: LegacyRedirect[] = [ { source: "/about-us", destination: "/about", permanent: true, reason: "canonical slug is /about" }, { source: "/architecture", destination: "/intel", permanent: true, reason: "architecture content lives under /intel" }, { source: "/specs", destination: "/intel", permanent: true, reason: "specs content lives under /intel" }, - { source: "/trust-center", destination: "/compliance-notice", permanent: true, reason: "renamed to /compliance-notice" }, { source: "/solutions", destination: "/services", permanent: true, reason: "renamed to /services" }, { source: "/pricing", destination: "/get-started", permanent: true, reason: "pricing entry is /get-started" }, { source: "/blog", destination: "/resources", permanent: true, reason: "blog content merged into /resources" }, diff --git a/src/proxy.ts b/src/proxy.ts index d96933b..adeafae 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,8 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionFromRequest, resolveAccessDestination } from "@/lib/auth"; -const PROTECTED_PREFIXES = ["/app", "/kyc", "/decision", "/portal", "/access"]; -const ADMIN_PREFIXES = ["/app/admin"]; +const PROTECTED_PREFIXES = [ + "/app", + "/kyc", + "/decision", + "/portal", + "/access", + "/account", + "/billing", + "/documents", + "/messages", + "/requests", + "/community-hub/members", + "/community-hub/messages", + "/community-hub/requests", + "/community-hub/profile", + "/community-hub/settings", + "/community-hub/opportunities", +]; +const ADMIN_PREFIXES = ["/app/admin", "/admin", "/review", "/compliance/admin"]; const ALWAYS_PUBLIC = [ "/", @@ -22,6 +39,8 @@ const ALWAYS_PUBLIC = [ "/privacy", "/terms", "/compliance-notice", + "/cookie-policy", + "/trust-center", "/client-login", "/forgot-password", "/api/health",