diff --git a/PROVIDER_SETUP.md b/PROVIDER_SETUP.md index 510faa0..9c5d26b 100644 --- a/PROVIDER_SETUP.md +++ b/PROVIDER_SETUP.md @@ -73,9 +73,22 @@ initialize Sentry. Keep auth tokens server-side only. - `ADMIN_ALERT_EMAIL=emplusbodyworks@gmail.com` - `CUSTOMER_SUPPORT_EMAIL=emplusbodyworks@gmail.com` - `PRODUCTION_APP_URL=https://polymarket-site-eta.vercel.app` +- `WEBHOOK_BASE_URL=https://polymarket-site-eta.vercel.app/api` - `RESTRICTED_JURISDICTIONS=US,CA-NY,CA-ON` - `LIVE_TRADING_ENABLED=false` +## Webhook URLs to paste into provider dashboards + +These endpoints store provider events in Neon for audit/reconciliation prep. +They do not move money or place orders. + +- Clerk: `https://polymarket-site-eta.vercel.app/api/webhooks/clerk` +- Veriff: `https://polymarket-site-eta.vercel.app/api/webhooks/veriff` +- Circle: `https://polymarket-site-eta.vercel.app/api/webhooks/circle` + +Provider event counts are visible at +`https://polymarket-site-eta.vercel.app/api/provider-events`. + ## What is still intentionally locked - Real deposits. diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index 111304e..13ed234 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -15,8 +15,11 @@ interfaces visible without moving funds or placing orders. Sentry browser DSN and Clerk publishable key once configured. - `/api/live` now checks the selected provider stack: Clerk, Neon, Veriff, Circle, and Sentry. +- `/api/webhooks/clerk`, `/api/webhooks/veriff`, and `/api/webhooks/circle` + receive provider events and store them in Neon. +- `/api/provider-events` reports provider event counts for a quick health check. - The Live Money tab shows launch checks, wallet/deposit settings, agent risk - limits, a dry-run order console, and an audit preview. + limits, provider webhook URLs, a dry-run order console, and an audit preview. The app still refuses to place live orders. That is intentional until the legal, provider, wallet-signing, reconciliation, and monitoring steps below are done. diff --git a/api/_db.js b/api/_db.js new file mode 100644 index 0000000..7f91b99 --- /dev/null +++ b/api/_db.js @@ -0,0 +1,110 @@ +import { neon } from "@neondatabase/serverless"; + +let sqlClient; +let schemaReady; + +export function databaseUrl() { + return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || ""; +} + +export function hasDatabase() { + return Boolean(databaseUrl()); +} + +export function sql() { + if (!sqlClient) { + const url = databaseUrl(); + if (!url) throw new Error("Database is not configured"); + sqlClient = neon(url); + } + return sqlClient; +} + +export async function ensureSchema() { + if (schemaReady) return; + const db = sql(); + + await db` + create table if not exists provider_events ( + id bigserial primary key, + provider text not null, + event_type text not null, + external_id text, + signature_valid boolean not null default false, + payload jsonb not null, + headers jsonb not null default '{}'::jsonb, + received_at timestamptz not null default now() + ) + `; + + await db` + create unique index if not exists provider_events_provider_external_id_idx + on provider_events (provider, external_id) + where external_id is not null + `; + + await db` + create table if not exists readiness_audit ( + id bigserial primary key, + event_type text not null, + detail jsonb not null, + created_at timestamptz not null default now() + ) + `; + + schemaReady = true; +} + +export async function recordProviderEvent(event) { + await ensureSchema(); + const db = sql(); + const payload = event.payload && typeof event.payload === "object" ? event.payload : {}; + const headers = event.headers && typeof event.headers === "object" ? event.headers : {}; + + const rows = await db` + insert into provider_events ( + provider, + event_type, + external_id, + signature_valid, + payload, + headers + ) values ( + ${event.provider}, + ${event.eventType || "unknown"}, + ${event.externalId || null}, + ${Boolean(event.signatureValid)}, + ${JSON.stringify(payload)}::jsonb, + ${JSON.stringify(headers)}::jsonb + ) + on conflict do nothing + returning id, received_at + `; + + return rows[0] || null; +} + +export async function recordAuditEvent(eventType, detail) { + await ensureSchema(); + const db = sql(); + await db` + insert into readiness_audit (event_type, detail) + values (${eventType}, ${JSON.stringify(detail || {})}::jsonb) + `; +} + +export async function providerEventSummary() { + await ensureSchema(); + const db = sql(); + const rows = await db` + select + provider, + count(*)::int as total, + max(received_at) as latest_received_at, + count(*) filter (where signature_valid)::int as signature_valid_count + from provider_events + group by provider + order by provider + `; + return rows; +} diff --git a/api/_webhook.js b/api/_webhook.js new file mode 100644 index 0000000..60622b6 --- /dev/null +++ b/api/_webhook.js @@ -0,0 +1,69 @@ +import crypto from "node:crypto"; + +export async function rawBody(req) { + if (typeof req.body === "string") return req.body; + if (req.body && typeof req.body === "object") return JSON.stringify(req.body); + + const chunks = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +export function parseJson(raw) { + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +export function safeHeaders(req, names) { + const allowed = new Set(names.map((name) => name.toLowerCase())); + return Object.fromEntries( + Object.entries(req.headers || {}) + .filter(([key]) => allowed.has(key.toLowerCase())) + .map(([key, value]) => [key.toLowerCase(), Array.isArray(value) ? value.join(",") : String(value)]) + ); +} + +function timingSafeEqualText(a, b) { + const left = Buffer.from(String(a || "")); + const right = Buffer.from(String(b || "")); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function signatureCandidates(signature) { + return String(signature || "") + .split(/[,\s]+/) + .map((piece) => piece.trim()) + .filter(Boolean) + .flatMap((piece) => { + const idx = piece.indexOf("="); + return idx === -1 ? [piece] : [piece.slice(idx + 1), piece]; + }) + .map((piece) => piece.replace(/^sha256=/i, "").trim()) + .filter(Boolean); +} + +export function verifyHmacSignature(raw, secret, signature) { + if (!secret || !signature) return false; + const hex = crypto.createHmac("sha256", secret).update(raw).digest("hex"); + const base64 = crypto.createHmac("sha256", secret).update(raw).digest("base64"); + return signatureCandidates(signature).some((candidate) => ( + timingSafeEqualText(candidate, hex) || timingSafeEqualText(candidate, base64) + )); +} + +export function firstHeader(req, names) { + for (const name of names) { + const value = req.headers?.[name.toLowerCase()]; + if (value) return Array.isArray(value) ? value.join(",") : String(value); + } + return ""; +} + +export function sendMethodNotAllowed(res) { + res.setHeader("Allow", "POST"); + return res.status(405).json({ ok: false, error: "Method not allowed" }); +} diff --git a/api/live.js b/api/live.js index dee0ae6..e01ac30 100644 --- a/api/live.js +++ b/api/live.js @@ -107,6 +107,12 @@ const LAUNCH_REQUIREMENTS = [ ["testing", "Paper-to-live parity, dry-runs, test wallets, webhook tests, and edge-case QA completed"], ]; +const WEBHOOK_ROUTES = [ + ["clerk", "Clerk user and session events", "/api/webhooks/clerk"], + ["veriff", "Veriff verification and review events", "/api/webhooks/veriff"], + ["circle", "Circle payment, wallet, and transfer events", "/api/webhooks/circle"], +]; + function envValue(key) { if (process.env[key]) return process.env[key]; return (ENV_ALIASES[key] || []).map((alias) => process.env[alias]).find(Boolean); @@ -143,6 +149,7 @@ function liveTradingReady() { function baseStatus() { const providers = providerStatus(); + const origin = (envValue("WEBHOOK_BASE_URL") || envValue("PRODUCTION_APP_URL") || "").replace(/\/api\/?$/, "").replace(/\/$/, ""); const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true"; const signatureTypeOk = String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3"; const chainOk = String(process.env.POLYMARKET_CHAIN_ID || "137") === "137"; @@ -162,6 +169,12 @@ function baseStatus() { providers, provider_stack: stackStatus(), launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })), + webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({ + provider, + label, + path, + url: origin ? `${origin}${path}` : path, + })), next_required: missing, docs: { polymarket_overview: "https://docs.polymarket.com/trading/overview", diff --git a/api/provider-events.js b/api/provider-events.js new file mode 100644 index 0000000..af791e7 --- /dev/null +++ b/api/provider-events.js @@ -0,0 +1,21 @@ +import { hasDatabase, providerEventSummary } from "./_db.js"; + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + + if (req.method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } + + if (!hasDatabase()) { + return res.status(503).json({ ok: false, error: "Database is not configured" }); + } + + try { + const providers = await providerEventSummary(); + return res.status(200).json({ ok: true, providers }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Provider event summary failed" }); + } +} diff --git a/api/webhooks/circle.js b/api/webhooks/circle.js new file mode 100644 index 0000000..6088035 --- /dev/null +++ b/api/webhooks/circle.js @@ -0,0 +1,51 @@ +import { recordProviderEvent } from "../_db.js"; +import { firstHeader, parseJson, rawBody, safeHeaders, sendMethodNotAllowed, verifyHmacSignature } from "../_webhook.js"; + +const SIGNATURE_HEADERS = [ + "circle-signature", + "x-circle-signature", + "x-signature", + "webhook-signature", +]; + +function eventType(payload) { + return payload.type || payload.eventType || payload.notificationType || "circle.webhook"; +} + +function externalId(payload) { + return payload.id || payload.notificationId || payload.data?.id || payload.resource?.id || null; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + if (req.method !== "POST") return sendMethodNotAllowed(res); + + try { + const body = await rawBody(req); + const payload = parseJson(body); + const headers = safeHeaders(req, SIGNATURE_HEADERS.concat(["user-agent"])); + const secret = process.env.CIRCLE_WEBHOOK_SECRET; + const signature = firstHeader(req, SIGNATURE_HEADERS); + const signatureValid = verifyHmacSignature(body, secret, signature); + + const recorded = await recordProviderEvent({ + provider: "circle", + eventType: eventType(payload), + externalId: externalId(payload), + signatureValid, + payload, + headers, + }); + + return res.status(202).json({ + ok: true, + recorded: Boolean(recorded), + provider: "circle", + event_type: eventType(payload), + signature_valid: signatureValid, + warning: signatureValid ? undefined : "Event recorded, but no recognized Circle signature was verified.", + }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Circle webhook failed" }); + } +} diff --git a/api/webhooks/clerk.js b/api/webhooks/clerk.js new file mode 100644 index 0000000..575db5c --- /dev/null +++ b/api/webhooks/clerk.js @@ -0,0 +1,56 @@ +import { Webhook } from "svix"; +import { recordProviderEvent } from "../_db.js"; +import { rawBody, parseJson, safeHeaders, sendMethodNotAllowed } from "../_webhook.js"; + +function eventType(payload) { + return payload.type || "clerk.webhook"; +} + +function externalId(payload) { + return payload.id || payload.data?.id || payload.data?.user_id || null; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + if (req.method !== "POST") return sendMethodNotAllowed(res); + + try { + const secret = process.env.CLERK_WEBHOOK_SIGNING_SECRET; + if (!secret) return res.status(503).json({ ok: false, error: "Clerk webhook secret is not configured" }); + + const body = await rawBody(req); + const headers = safeHeaders(req, ["svix-id", "svix-timestamp", "svix-signature"]); + let payload; + + try { + payload = new Webhook(secret).verify(body, headers); + } catch { + return res.status(401).json({ ok: false, error: "Invalid Clerk webhook signature" }); + } + + const recorded = await recordProviderEvent({ + provider: "clerk", + eventType: eventType(payload), + externalId: externalId(payload), + signatureValid: true, + payload, + headers, + }); + + return res.status(200).json({ + ok: true, + recorded: Boolean(recorded), + provider: "clerk", + event_type: eventType(payload), + signature_valid: true, + }); + } catch (err) { + const payload = parseJson(req.body); + return res.status(500).json({ + ok: false, + error: err?.message || "Clerk webhook failed", + provider: "clerk", + event_type: eventType(payload), + }); + } +} diff --git a/api/webhooks/veriff.js b/api/webhooks/veriff.js new file mode 100644 index 0000000..21faa63 --- /dev/null +++ b/api/webhooks/veriff.js @@ -0,0 +1,51 @@ +import { recordProviderEvent } from "../_db.js"; +import { firstHeader, parseJson, rawBody, safeHeaders, sendMethodNotAllowed, verifyHmacSignature } from "../_webhook.js"; + +const SIGNATURE_HEADERS = [ + "x-hmac-signature", + "x-veriff-signature", + "veriff-signature", + "x-signature", +]; + +function eventType(payload) { + return payload.action || payload.eventType || payload.type || payload.status || "veriff.webhook"; +} + +function externalId(payload) { + return payload.id || payload.sessionId || payload.verification?.id || payload.verification?.vendorData || payload.vendorData || null; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + if (req.method !== "POST") return sendMethodNotAllowed(res); + + try { + const body = await rawBody(req); + const payload = parseJson(body); + const headers = safeHeaders(req, SIGNATURE_HEADERS.concat(["user-agent"])); + const secret = process.env.VERIFF_WEBHOOK_SECRET || process.env.VERIFF_SECRET_KEY; + const signature = firstHeader(req, SIGNATURE_HEADERS); + const signatureValid = verifyHmacSignature(body, secret, signature); + + const recorded = await recordProviderEvent({ + provider: "veriff", + eventType: eventType(payload), + externalId: externalId(payload), + signatureValid, + payload, + headers, + }); + + return res.status(202).json({ + ok: true, + recorded: Boolean(recorded), + provider: "veriff", + event_type: eventType(payload), + signature_valid: signatureValid, + warning: signatureValid ? undefined : "Event recorded, but no recognized Veriff signature was verified.", + }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Veriff webhook failed" }); + } +} diff --git a/index.html b/index.html index f44d4e5..ec65d5b 100644 --- a/index.html +++ b/index.html @@ -598,6 +598,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
+