From 6b92867b6985174dea0f25bf0dcf91a1f98f640a Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 17:20:46 +0100 Subject: [PATCH 1/2] fix: add durable controlled-launch contact intake --- src/__tests__/contact-intake-gateway.test.ts | 55 ++++ src/app/api/contact/route.ts | 46 ++- src/lib/supabase-gateway.ts | 29 ++ .../functions/gem-contact-gateway/deno.json | 6 + .../functions/gem-contact-gateway/index.ts | 294 ++++++++++++++++++ 5 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/contact-intake-gateway.test.ts create mode 100644 supabase/functions/gem-contact-gateway/deno.json create mode 100644 supabase/functions/gem-contact-gateway/index.ts diff --git a/src/__tests__/contact-intake-gateway.test.ts b/src/__tests__/contact-intake-gateway.test.ts new file mode 100644 index 00000000..c4cad268 --- /dev/null +++ b/src/__tests__/contact-intake-gateway.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +const routeSource = readFileSync("src/app/api/contact/route.ts", "utf8"); +const gatewaySource = readFileSync("src/lib/supabase-gateway.ts", "utf8"); +const functionSource = readFileSync( + "supabase/functions/gem-contact-gateway/index.ts", + "utf8", +); +const formSource = readFileSync("src/app/contact/ContactForm.tsx", "utf8"); + +describe("controlled launch contact intake", () => { + it("uses the Supabase gateway when direct database access is unavailable", () => { + expect(routeSource).toContain("shouldUseSupabaseGateway()"); + expect(routeSource).toContain("submitContactGateway({"); + expect(routeSource.indexOf("shouldUseSupabaseGateway()")).toBeLessThan( + routeSource.indexOf("db.supportBooking.create"), + ); + expect(gatewaySource).toContain("submitContactGateway"); + expect(gatewaySource).toContain('"gem-contact-gateway"'); + }); + + it("keeps Prisma as the preferred direct-database path", () => { + expect(routeSource).toContain("db.supportBooking.create"); + expect(routeSource).toContain('persistence: "prisma"'); + }); + + it("does not report success when the gateway cannot persist the message", () => { + expect(gatewaySource).toContain("if (!response.ok)"); + expect(gatewaySource).toContain("GATEWAY_UNAVAILABLE"); + expect(routeSource).toContain("Your message could not be stored"); + expect(routeSource).toContain("{ status: 503 }"); + }); + + it("stores the enquiry before sending an optional notification", () => { + expect(functionSource.indexOf('.from("support_bookings").insert')).toBeLessThan( + functionSource.indexOf("await sendNotification"), + ); + expect(functionSource).toContain("Notification delivery: pending"); + expect(functionSource).toContain("notificationDelivery"); + }); + + it("applies spam controls and records an audit event", () => { + expect(functionSource).toContain("MAX_SUBMISSIONS_PER_EMAIL_PER_HOUR"); + expect(functionSource).toContain('"RATE_LIMITED"'); + expect(functionSource).toContain('resource: "contact_message"'); + expect(functionSource).toContain('source: "supabase_contact_gateway"'); + }); + + it("retains the honeypot and accurate success wording", () => { + expect(routeSource).toContain("Honeypot submissions"); + expect(formSource).toContain("accepted by our contact system"); + expect(formSource).not.toContain("guaranteed response"); + }); +}); diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index 954fd0ff..4ea53f90 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -6,6 +6,10 @@ 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"; +import { + shouldUseSupabaseGateway, + submitContactGateway, +} from "@/lib/supabase-gateway"; const schema = z.object({ name: z.string().trim().min(2, "Name must be at least 2 characters").max(120), @@ -44,8 +48,47 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: true }); } - const session = await getSession(); const normalizedSubject = subject?.trim() || `Contact from ${name}`; + + // During the controlled launch, the canonical Vercel runtime may operate + // without a direct Prisma connection. Persist public enquiries through the + // Supabase gateway instead of claiming success without durable storage. + if (shouldUseSupabaseGateway()) { + try { + const submission = await submitContactGateway({ + name, + email, + subject: normalizedSubject, + message, + website, + ipAddress, + userAgent, + }); + + return NextResponse.json({ + ok: true, + submissionId: submission.submissionId, + ticketId: submission.ticketId, + persistence: submission.persistence, + }); + } catch (error) { + console.error("[contact] Supabase intake gateway unavailable", { + code: + error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown_error", + }); + return NextResponse.json( + { + ok: false, + error: "Your message could not be stored. Please use the published support email.", + }, + { status: 503 }, + ); + } + } + + const session = await getSession(); const baseNotes = [ `Website contact enquiry`, `From: ${name} <${email}>`, @@ -148,6 +191,7 @@ export async function POST(req: NextRequest) { ok: true, submissionId: submission.id, ticketId, + persistence: "prisma", }); } catch (error) { console.error("[contact] failed to persist public enquiry", error); diff --git a/src/lib/supabase-gateway.ts b/src/lib/supabase-gateway.ts index 98800883..de05ab84 100644 --- a/src/lib/supabase-gateway.ts +++ b/src/lib/supabase-gateway.ts @@ -396,3 +396,32 @@ export async function completePasswordRecoveryGateway( }, ); } + +export interface ContactGatewaySubmission { + name: string; + email: string; + subject: string; + message: string; + website?: string; + ipAddress?: string; + userAgent?: string; +} + +export interface ContactGatewayResult { + ok: true; + accepted: true; + submissionId?: string; + ticketId: null; + persistence: "supabase_gateway"; + notificationDelivery?: "sent" | "failed" | "not_configured"; + suppressed?: boolean; +} + +export async function submitContactGateway( + submission: ContactGatewaySubmission, +): Promise { + return invokeGateway("gem-contact-gateway", { + action: "submit", + ...submission, + }); +} diff --git a/supabase/functions/gem-contact-gateway/deno.json b/supabase/functions/gem-contact-gateway/deno.json new file mode 100644 index 00000000..ee967bae --- /dev/null +++ b/supabase/functions/gem-contact-gateway/deno.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["deno.ns", "dom"] + } +} diff --git a/supabase/functions/gem-contact-gateway/index.ts b/supabase/functions/gem-contact-gateway/index.ts new file mode 100644 index 00000000..71ae11b9 --- /dev/null +++ b/supabase/functions/gem-contact-gateway/index.ts @@ -0,0 +1,294 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "npm:@supabase/supabase-js@2.49.8"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || ""; +const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; +const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") || ""; +const CONTACT_EMAIL_FROM = + Deno.env.get("CONTACT_EMAIL_FROM") || Deno.env.get("RESET_EMAIL_FROM") || ""; +const CONTACT_NOTIFICATION_TO = + Deno.env.get("CONTACT_NOTIFICATION_TO") || + Deno.env.get("ADMIN_EMAIL") || + Deno.env.get("GEM_OWNER_EMAIL") || + "admin@gemcybersecurityassist.com"; +const MAX_SUBMISSIONS_PER_EMAIL_PER_HOUR = 3; + +if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new Error("Missing Supabase runtime configuration"); +} + +const db = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, +}); + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", +}; + +class ContactGatewayError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + ...CORS_HEADERS, + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); +} + +function requiredString( + value: unknown, + field: string, + minLength: number, + maxLength: number, +): string { + if (typeof value !== "string") { + throw new ContactGatewayError(400, "INVALID_REQUEST", `${field} is required.`); + } + const normalized = value.trim(); + if (normalized.length < minLength || normalized.length > maxLength) { + throw new ContactGatewayError(400, "INVALID_REQUEST", `${field} is invalid.`); + } + return normalized; +} + +function optionalString(value: unknown, maxLength: number): string { + if (typeof value !== "string") return ""; + return value.trim().slice(0, maxLength); +} + +function normalizeEmail(value: unknown): string { + const email = requiredString(value, "email", 3, 254).toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new ContactGatewayError(400, "INVALID_REQUEST", "email is invalid."); + } + return email; +} + +async function enforceEmailRateLimit(email: string) { + const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { count, error } = await db + .from("support_bookings") + .select("id", { count: "exact", head: true }) + .eq("email", email) + .gte("createdAt", cutoff); + + if (error) { + console.error("contact_gateway_rate_limit_query_failed", error.message); + throw new ContactGatewayError(503, "DATABASE_ERROR", "Contact intake is unavailable."); + } + if ((count || 0) >= MAX_SUBMISSIONS_PER_EMAIL_PER_HOUR) { + throw new ContactGatewayError( + 429, + "RATE_LIMITED", + "Too many contact requests. Please try again later.", + ); + } +} + +async function sendNotification(input: { + submissionId: string; + name: string; + email: string; + subject: string; + message: string; +}) { + if (!RESEND_API_KEY || !CONTACT_EMAIL_FROM || !CONTACT_NOTIFICATION_TO) { + return "not_configured"; + } + + try { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: CONTACT_EMAIL_FROM, + to: [CONTACT_NOTIFICATION_TO], + reply_to: input.email, + subject: `[GEM Contact] ${input.subject}`, + text: [ + `Submission ID: ${input.submissionId}`, + `Name: ${input.name}`, + `Email: ${input.email}`, + "", + input.message, + ].join("\n"), + }), + }); + + if (!response.ok) { + const failure = (await response.json().catch(() => ({}))) as { name?: string }; + console.error( + "contact_gateway_notification_failed", + response.status, + failure.name || "unknown", + ); + return "failed"; + } + return "sent"; + } catch (error) { + console.error( + "contact_gateway_notification_failed", + error instanceof Error ? error.name : "unknown", + ); + return "failed"; + } +} + +async function recordAudit(input: { + submissionId: string; + subject: string; + notificationDelivery: string; + ipAddress: string; + userAgent: string; +}) { + const { error } = await db.from("audit_logs").insert({ + id: crypto.randomUUID(), + userId: null, + action: "admin_action", + resource: "contact_message", + resourceId: input.submissionId, + metadata: { + source: "supabase_contact_gateway", + subject: input.subject, + notificationDelivery: input.notificationDelivery, + }, + ipAddress: input.ipAddress || null, + userAgent: input.userAgent || null, + createdAt: new Date().toISOString(), + }); + + if (error) console.error("contact_gateway_audit_failed", error.message); +} + +async function submitContact(body: Record) { + const website = optionalString(body.website, 500); + if (website) { + return { ok: true, accepted: true, suppressed: true }; + } + + const name = requiredString(body.name, "name", 2, 120); + const email = normalizeEmail(body.email); + const subject = requiredString(body.subject, "subject", 1, 200); + const message = requiredString(body.message, "message", 10, 5_000); + const ipAddress = optionalString(body.ipAddress, 128); + const userAgent = optionalString(body.userAgent, 1_024); + + await enforceEmailRateLimit(email); + + const submissionId = crypto.randomUUID(); + const now = new Date().toISOString(); + const baseNotes = [ + "Website contact enquiry", + `From: ${name} <${email}>`, + "Authenticated: no (public gateway fallback)", + "Persistence: Supabase contact gateway", + "", + message, + ].join("\n"); + + const { error: insertError } = await db.from("support_bookings").insert({ + id: submissionId, + sessionId: null, + userId: null, + name, + email, + subject, + preferredAt: null, + status: "pending", + notes: `${baseNotes}\n\nNotification delivery: pending`, + createdAt: now, + updatedAt: now, + }); + + if (insertError) { + console.error("contact_gateway_insert_failed", insertError.message); + throw new ContactGatewayError(503, "DATABASE_ERROR", "Contact intake is unavailable."); + } + + const notificationDelivery = await sendNotification({ + submissionId, + name, + email, + subject, + message, + }); + + const { error: updateError } = await db + .from("support_bookings") + .update({ + notes: `${baseNotes}\n\nNotification delivery: ${notificationDelivery}`, + updatedAt: new Date().toISOString(), + }) + .eq("id", submissionId); + if (updateError) console.error("contact_gateway_delivery_status_update_failed", updateError.message); + + await recordAudit({ + submissionId, + subject, + notificationDelivery, + ipAddress, + userAgent, + }); + + return { + ok: true, + accepted: true, + submissionId, + ticketId: null, + persistence: "supabase_gateway", + notificationDelivery, + }; +} + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") return new Response("ok", { headers: CORS_HEADERS }); + if (request.method === "GET") { + return json({ + ok: true, + service: "gem-contact-gateway", + version: "1.0.0", + persistence: "support_bookings", + notificationConfigured: Boolean( + RESEND_API_KEY && CONTACT_EMAIL_FROM && CONTACT_NOTIFICATION_TO, + ), + failClosed: true, + }); + } + if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); + + try { + const body = (await request.json()) as Record; + if (body.action !== "submit") { + throw new ContactGatewayError(400, "UNKNOWN_ACTION", "Unknown action."); + } + return json(await submitContact(body)); + } catch (error) { + if (error instanceof ContactGatewayError) { + return json({ error: error.message, code: error.code }, error.status); + } + console.error( + "contact_gateway_internal_error", + error instanceof Error ? error.name : "unknown", + ); + return json( + { error: "Contact intake is unavailable.", code: "INTERNAL_ERROR" }, + 500, + ); + } +}); From 026c9fdbbcea480d2a7560cd5ac95608b3069e3e Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 17:21:39 +0100 Subject: [PATCH 2/2] ci: verify pull requests on canonical Node runtime --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bde4dff..5a538a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,16 @@ -name: Manual Build Verification +name: Build Verification on: workflow_dispatch: + pull_request: + branches: + - main permissions: contents: read concurrency: - group: manual-build-verification-${{ github.ref }} + group: build-verification-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -41,7 +44,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: pnpm - name: Install dependencies