Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 15 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
},
],
},
{
Expand All @@ -90,7 +102,9 @@ const nextConfig = {

// Redirects
async redirects() {
return [];
return [
{ source: '/blog', destination: '/resources', permanent: true },
];
},

// Rewrites
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/production-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
67 changes: 67 additions & 0 deletions src/app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions src/app/cookie-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="mx-auto max-w-4xl px-4 py-20"><p className="text-xs uppercase tracking-[0.25em] text-cyan-300">Legal</p><h1 className="mt-3 text-4xl font-bold">Cookie Policy</h1><p className="mt-5 text-slate-300">GEM Enterprise uses limited cookies and similar technologies to operate a secure, KYC-gated platform. We do not use cookies to sell personal information.</p><div className="mt-10 overflow-hidden rounded-2xl border border-white/10"><table className="w-full text-left text-sm"><thead className="bg-white/10 text-white"><tr><th className="p-4">Category</th><th className="p-4">Purpose</th><th className="p-4">Consent</th></tr></thead><tbody>{rows.map(([a,b,c])=><tr key={a} className="border-t border-white/10"><td className="p-4 font-semibold text-cyan-200">{a}</td><td className="p-4 text-slate-300">{b}</td><td className="p-4 text-slate-300">{c}</td></tr>)}</tbody></table></div><h2 className="mt-10 text-2xl font-semibold">Consent behavior</h2><p className="mt-3 text-slate-300">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.</p><h2 className="mt-10 text-2xl font-semibold">Current session cookie</h2><p className="mt-3 text-slate-300">The authenticated portal uses the <code className="rounded bg-white/10 px-1">gem_session</code> cookie with HttpOnly, Secure in production, and SameSite=Lax settings.</p></section>}
107 changes: 107 additions & 0 deletions src/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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<FormState>(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<HTMLFormElement>) {
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 (
<section className="mx-auto max-w-xl px-4 py-24">
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-8">
<p className="text-xs uppercase tracking-[0.25em] text-cyan-300">Account recovery</p>
<h1 className="mt-3 text-4xl font-bold">Forgot password</h1>
<p className="mt-4 text-slate-300">
Enter your account email. For security, responses do not reveal whether an email is registered.
</p>

<form onSubmit={submit} className="mt-8 space-y-4">
<label className="block text-sm text-slate-300">
Email address
<input
required
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950 px-4 py-3 text-white"
/>
</label>
<button
disabled={state === "loading"}
className="w-full rounded-xl bg-cyan-300 px-4 py-3 font-semibold text-slate-950 disabled:opacity-60"
>
{state === "loading" ? "Sending…" : "Send reset instructions"}
</button>
</form>

{message ? (
<div
role="status"
className={`mt-5 rounded-xl border p-4 text-sm ${
state === "success"
? "border-emerald-400/30 bg-emerald-400/10 text-emerald-200"
: "border-amber-400/30 bg-amber-400/10 text-amber-100"
}`}
>
{message}
</div>
) : null}

<p className="mt-6 text-sm text-slate-400">
<Link className="text-cyan-300" href="/client-login">
Return to client login
</Link>
</p>
</div>
</section>
);
}

export default function ForgotPasswordPage() {
return (
<Suspense fallback={<section className="mx-auto max-w-xl px-4 py-24 text-slate-300">Loading recovery form…</section>}>
<ForgotPasswordForm />
</Suspense>
);
}
30 changes: 30 additions & 0 deletions src/app/robots.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
26 changes: 26 additions & 0 deletions src/app/sitemap.xml/route.ts
Original file line number Diff line number Diff line change
@@ -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) => `<url><loc>${baseUrl}${route}</loc></url>`).join("");
const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;

return new Response(xml, { headers: { "content-type": "application/xml" } });
}
6 changes: 6 additions & 0 deletions src/app/trust-center/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="mx-auto max-w-5xl px-4 py-20"><p className="text-xs uppercase tracking-[0.25em] text-cyan-300">Trust Center</p><h1 className="mt-3 text-4xl font-bold">Security and compliance overview</h1><p className="mt-5 max-w-3xl text-slate-300">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.</p><div className="mt-10 grid gap-5 md:grid-cols-2"><Card title="Security overview">Defense-in-depth access controls, encrypted transport, protected session cookies, role-based authorization, audit logging, and KYC-gated workflows.</Card><Card title="Responsible disclosure">Report suspected vulnerabilities to <a className="text-cyan-300" href="mailto:security@gemcybersecurityassist.com">security@gemcybersecurityassist.com</a>. Provide reproducible details and avoid accessing client data.</Card><Card title="Data protection">Client data is handled under least-privilege access, retention controls, and documented operational review. Evidence records follow the platform retention model.</Card><Card title="Subprocessors">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.</Card></div><h2 className="mt-12 text-2xl font-semibold">Compliance mapping</h2><ul className="mt-4 grid gap-3 md:grid-cols-2">{mappings.map(m=><li key={m} className="rounded-xl border border-white/10 bg-white/[0.03] p-4 text-slate-300">{m}</li>)}</ul><div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.03] p-6"><h2 className="text-2xl font-semibold">Vendor due diligence request</h2><p className="mt-3 text-slate-300">Qualified prospects and clients may request security questionnaires, architecture summaries, insurance evidence, and available attestations.</p><Link href="/contact?topic=vendor-due-diligence" className="mt-5 inline-flex rounded-xl bg-cyan-300 px-5 py-3 font-semibold text-slate-950">Request due diligence materials</Link><p className="mt-4 text-sm text-slate-400">Status page: <span className="text-white">placeholder pending connected uptime provider</span>.</p></div></section>}
function Card({title,children}:{title:string;children:React.ReactNode}){return <div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6"><h2 className="text-xl font-semibold">{title}</h2><p className="mt-3 text-slate-300">{children}</p></div>}
2 changes: 1 addition & 1 deletion src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
23 changes: 22 additions & 1 deletion src/lib/siteRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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" },
Expand Down
23 changes: 21 additions & 2 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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 = [
"/",
Expand All @@ -22,6 +39,8 @@ const ALWAYS_PUBLIC = [
"/privacy",
"/terms",
"/compliance-notice",
"/cookie-policy",
"/trust-center",
"/client-login",
"/forgot-password",
"/api/health",
Expand Down
Loading