mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-28 00:17:46 +00:00
Add provider webhook event store
This commit is contained in:
+110
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
+13
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user