From 3cc818a13fa3939e7c3e83193b945b36c6dd18b3 Mon Sep 17 00:00:00 2001 From: Theodore Song Date: Mon, 13 Jul 2026 16:47:06 -0400 Subject: [PATCH] Add live money readiness controls --- .env.example | 2 + PROVIDER_SETUP.md | 10 +++++ REAL_MONEY_ROADMAP.md | 7 +++ api/_db.js | 101 ++++++++++++++++++++++++++++++++++++++++++ api/consent.js | 52 ++++++++++++++++++++++ api/incident.js | 48 ++++++++++++++++++++ api/live.js | 7 +++ api/policies.js | 47 ++++++++++++++++++++ api/risk-profile.js | 60 +++++++++++++++++++++++++ index.html | 83 +++++++++++++++++++++++++++++++--- 10 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 api/consent.js create mode 100644 api/incident.js create mode 100644 api/policies.js create mode 100644 api/risk-profile.js diff --git a/.env.example b/.env.example index e0480e6..741c09c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ TERMS_VERSION= PRIVACY_VERSION= RISK_DISCLOSURE_VERSION= MARKET_POLICY_STORE= +CONSENT_STORE= # Wallet / deposit-wallet provider WALLET_PROVIDER=Circle @@ -74,6 +75,7 @@ SENTRY_ORG= SENTRY_PROJECT= SENTRY_WEBHOOK_URL= INCIDENT_WEBHOOK_URL= +RISK_ADMIN_TOKEN= ADMIN_ALERT_EMAIL= CUSTOMER_SUPPORT_EMAIL= RISK_ADMIN_WALLET= diff --git a/PROVIDER_SETUP.md b/PROVIDER_SETUP.md index 9c5d26b..6a804ab 100644 --- a/PROVIDER_SETUP.md +++ b/PROVIDER_SETUP.md @@ -96,5 +96,15 @@ Provider event counts are visible at - Live Polymarket order placement. - Automatic agent trading with real funds. +## Newly wired backend endpoints + +- Policies: `https://polymarket-site-eta.vercel.app/api/policies` +- Consent recording: `https://polymarket-site-eta.vercel.app/api/consent` +- Server risk profile: `https://polymarket-site-eta.vercel.app/api/risk-profile` +- Incident alerts: `https://polymarket-site-eta.vercel.app/api/incident` + +The consent and incident endpoints are intentionally gated: consent needs real +approved policy versions, and incident alerts need `RISK_ADMIN_TOKEN`. + Those should stay locked until legal review, eligibility checks, signed-order execution, reconciliation, audit logs, and incident response are complete. diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index 13ed234..fd492c9 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -18,6 +18,13 @@ interfaces visible without moving funds or placing orders. - `/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. +- `/api/policies` exposes the active policy/readiness state. +- `/api/consent` records versioned user consent after approved versions are + configured. +- `/api/risk-profile` stores wallet, jurisdiction, allocation, and agent + permission settings server-side. +- `/api/incident` records incident events and can notify the configured incident + webhook after `RISK_ADMIN_TOKEN` is set. - The Live Money tab shows launch checks, wallet/deposit settings, agent risk limits, provider webhook URLs, a dry-run order console, and an audit preview. diff --git a/api/_db.js b/api/_db.js index 7f91b99..3741b3f 100644 --- a/api/_db.js +++ b/api/_db.js @@ -52,6 +52,31 @@ export async function ensureSchema() { ) `; + await db` + create table if not exists user_consents ( + id bigserial primary key, + user_id text not null, + terms_version text not null, + privacy_version text not null, + risk_disclosure_version text not null, + jurisdiction text, + payload jsonb not null default '{}'::jsonb, + accepted_at timestamptz not null default now() + ) + `; + + await db` + create table if not exists risk_profiles ( + user_id text primary key, + wallet_address text, + jurisdiction text, + deposit_limit numeric not null default 0, + withdraw_reserve numeric not null default 0, + agent_permissions jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now() + ) + `; + schemaReady = true; } @@ -93,6 +118,82 @@ export async function recordAuditEvent(eventType, detail) { `; } +export async function recordUserConsent(consent) { + await ensureSchema(); + const db = sql(); + const rows = await db` + insert into user_consents ( + user_id, + terms_version, + privacy_version, + risk_disclosure_version, + jurisdiction, + payload + ) values ( + ${consent.userId}, + ${consent.termsVersion}, + ${consent.privacyVersion}, + ${consent.riskDisclosureVersion}, + ${consent.jurisdiction || null}, + ${JSON.stringify(consent.payload || {})}::jsonb + ) + returning id, accepted_at + `; + return rows[0] || null; +} + +export async function upsertRiskProfile(profile) { + await ensureSchema(); + const db = sql(); + const rows = await db` + insert into risk_profiles ( + user_id, + wallet_address, + jurisdiction, + deposit_limit, + withdraw_reserve, + agent_permissions, + updated_at + ) values ( + ${profile.userId}, + ${profile.walletAddress || null}, + ${profile.jurisdiction || null}, + ${Number(profile.depositLimit || 0)}, + ${Number(profile.withdrawReserve || 0)}, + ${JSON.stringify(profile.agentPermissions || {})}::jsonb, + now() + ) + on conflict (user_id) do update set + wallet_address = excluded.wallet_address, + jurisdiction = excluded.jurisdiction, + deposit_limit = excluded.deposit_limit, + withdraw_reserve = excluded.withdraw_reserve, + agent_permissions = excluded.agent_permissions, + updated_at = now() + returning user_id, updated_at + `; + return rows[0] || null; +} + +export async function latestRiskProfile(userId) { + await ensureSchema(); + const db = sql(); + const rows = await db` + select + user_id, + wallet_address, + jurisdiction, + deposit_limit, + withdraw_reserve, + agent_permissions, + updated_at + from risk_profiles + where user_id = ${userId} + limit 1 + `; + return rows[0] || null; +} + export async function providerEventSummary() { await ensureSchema(); const db = sql(); diff --git a/api/consent.js b/api/consent.js new file mode 100644 index 0000000..b463072 --- /dev/null +++ b/api/consent.js @@ -0,0 +1,52 @@ +import { recordAuditEvent, recordUserConsent } from "./_db.js"; + +function activeVersions() { + return { + termsVersion: process.env.TERMS_VERSION || "", + privacyVersion: process.env.PRIVACY_VERSION || "", + riskDisclosureVersion: process.env.RISK_DISCLOSURE_VERSION || "", + }; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } + + try { + const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); + const userId = String(body.user_id || "").trim(); + const jurisdiction = String(body.jurisdiction || "").trim(); + const accepted = Boolean(body.accept_terms && body.accept_privacy && body.accept_risk); + const versions = activeVersions(); + const missing = []; + + if (!userId) missing.push("user_id"); + if (!accepted) missing.push("accept_terms, accept_privacy, and accept_risk"); + if (!versions.termsVersion) missing.push("TERMS_VERSION"); + if (!versions.privacyVersion) missing.push("PRIVACY_VERSION"); + if (!versions.riskDisclosureVersion) missing.push("RISK_DISCLOSURE_VERSION"); + + if (missing.length) { + return res.status(400).json({ ok: false, error: "Consent is not ready to record", missing }); + } + + const recorded = await recordUserConsent({ + userId, + jurisdiction, + ...versions, + payload: { + source: "live_money_readiness", + user_agent: req.headers["user-agent"] || "", + }, + }); + await recordAuditEvent("USER_CONSENT_RECORDED", { user_id: userId, jurisdiction, ...versions }); + + return res.status(200).json({ ok: true, recorded }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Consent recording failed" }); + } +} diff --git a/api/incident.js b/api/incident.js new file mode 100644 index 0000000..2565d2a --- /dev/null +++ b/api/incident.js @@ -0,0 +1,48 @@ +import { recordAuditEvent } from "./_db.js"; + +async function notifyIncidentWebhook(payload) { + const url = process.env.INCIDENT_WEBHOOK_URL || process.env.SENTRY_WEBHOOK_URL; + if (!url) return { sent: false, reason: "No incident webhook is configured" }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + return { sent: response.ok, status: response.status }; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } + + try { + const adminToken = process.env.RISK_ADMIN_TOKEN || ""; + if (adminToken && req.headers["x-admin-token"] !== adminToken) { + return res.status(401).json({ ok: false, error: "Invalid admin token" }); + } + if (!adminToken) { + return res.status(503).json({ ok: false, error: "Incident endpoint is locked until RISK_ADMIN_TOKEN is configured" }); + } + + const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); + const payload = { + at: new Date().toISOString(), + severity: String(body.severity || "warning"), + title: String(body.title || "Polymarket Arena incident"), + detail: String(body.detail || ""), + live_trading_enabled: process.env.LIVE_TRADING_ENABLED === "true", + }; + + await recordAuditEvent("INCIDENT_REPORTED", payload); + const notification = await notifyIncidentWebhook(payload); + return res.status(200).json({ ok: true, notification, payload }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Incident report failed" }); + } +} diff --git a/api/live.js b/api/live.js index e01ac30..fd8d9ad 100644 --- a/api/live.js +++ b/api/live.js @@ -1,3 +1,5 @@ +import { recordAuditEvent } from "./_db.js"; + const REQUIRED_ENV = [ ["PRODUCTION_APP_URL", "Production app URL"], ["AUTH_PROVIDER", "Real account authentication provider"], @@ -16,6 +18,7 @@ const REQUIRED_ENV = [ ["PRIVACY_VERSION", "Approved privacy policy version"], ["RISK_DISCLOSURE_VERSION", "Approved risk disclosure version"], ["MARKET_POLICY_STORE", "Market eligibility and category policy store"], + ["CONSENT_STORE", "Versioned user consent store"], ["PAYMENTS_PROVIDER", "Deposit and withdrawal provider"], ["PAYMENTS_API_KEY", "Payments provider API key"], ["PAYMENTS_WEBHOOK_SECRET", "Payments webhook secret"], @@ -36,6 +39,7 @@ const REQUIRED_ENV = [ ["AUDIT_LOG_STORE", "Durable audit log store"], ["MONITORING_DSN", "Error and performance monitoring"], ["INCIDENT_WEBHOOK_URL", "Incident alert webhook"], + ["RISK_ADMIN_TOKEN", "Risk admin incident-control token"], ["ADMIN_ALERT_EMAIL", "Admin alert destination"], ["CUSTOMER_SUPPORT_EMAIL", "Customer support contact"], ]; @@ -54,6 +58,7 @@ const ENV_ALIASES = { ACCOUNTING_EXPORT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"], RATE_LIMIT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"], AUDIT_LOG_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"], + CONSENT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"], MONITORING_DSN: ["SENTRY_DSN"], INCIDENT_WEBHOOK_URL: ["SENTRY_WEBHOOK_URL"], }; @@ -223,6 +228,7 @@ export default async function handler(req, res) { }; if (!status.live_trading_enabled) { + await recordAuditEvent("ORDER_INTENT_BLOCKED", auditEvent); return res.status(423).json({ ok: false, dry_run: true, @@ -232,6 +238,7 @@ export default async function handler(req, res) { }); } + await recordAuditEvent("ORDER_INTENT_NOT_IMPLEMENTED", auditEvent); return res.status(501).json({ ok: false, dry_run: true, diff --git a/api/policies.js b/api/policies.js new file mode 100644 index 0000000..b67217e --- /dev/null +++ b/api/policies.js @@ -0,0 +1,47 @@ +function envValue(key, fallback = "") { + return process.env[key] || fallback; +} + +function splitList(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +const MARKET_RULES = [ + "No trading for restricted jurisdictions or users who fail eligibility checks.", + "No market categories may be enabled until a written market policy is approved.", + "Agent orders must obey per-user allocation caps, per-market caps, stop rules, and emergency pause.", + "Users must be able to revoke agent permissions and withdraw available funds.", + "Every signal, consent, order intent, fill, cancellation, deposit, and withdrawal must be audit logged.", +]; + +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" }); + } + + const versions = { + terms: envValue("TERMS_VERSION"), + privacy: envValue("PRIVACY_VERSION"), + risk_disclosure: envValue("RISK_DISCLOSURE_VERSION"), + }; + const approved = Boolean(versions.terms && versions.privacy && versions.risk_disclosure && envValue("MARKET_POLICY_STORE")); + + return res.status(200).json({ + ok: true, + approved, + status: approved ? "configured" : "draft_not_approved", + versions, + restricted_jurisdictions: splitList(envValue("RESTRICTED_JURISDICTIONS")), + market_policy_store: envValue("MARKET_POLICY_STORE"), + live_trading_enabled: process.env.LIVE_TRADING_ENABLED === "true", + rules: MARKET_RULES, + note: approved + ? "Policy versions are configured, but live trading still requires all other launch checks." + : "Draft policy guardrails are available. Legal approval and versioned documents are still required before real money.", + }); +} diff --git a/api/risk-profile.js b/api/risk-profile.js new file mode 100644 index 0000000..8586167 --- /dev/null +++ b/api/risk-profile.js @@ -0,0 +1,60 @@ +import { latestRiskProfile, recordAuditEvent, upsertRiskProfile } from "./_db.js"; + +function validateProfile(profile) { + const errors = []; + if (!profile.userId) errors.push("user_id"); + if (Number(profile.depositLimit) < 0) errors.push("deposit_limit must be 0 or greater"); + if (Number(profile.withdrawReserve) < 0) errors.push("withdraw_reserve must be 0 or greater"); + + Object.entries(profile.agentPermissions || {}).forEach(([agentId, permission]) => { + if (Number(permission.maxAllocation || 0) < 0) errors.push(`${agentId} maxAllocation must be 0 or greater`); + const maxPositionPct = Number(permission.maxPositionPct || 0); + if (maxPositionPct < 0 || maxPositionPct > 100) errors.push(`${agentId} maxPositionPct must be between 0 and 100`); + if (permission.autoTrade && !permission.manualApprove) { + errors.push(`${agentId} cannot enable unattended real-money trading before final approval`); + } + }); + + return errors; +} + +export default async function handler(req, res) { + res.setHeader("Cache-Control", "no-store"); + + try { + if (req.method === "GET") { + const userId = String(req.query?.user_id || "").trim(); + if (!userId) return res.status(400).json({ ok: false, error: "user_id is required" }); + const profile = await latestRiskProfile(userId); + return res.status(200).json({ ok: true, profile }); + } + + if (req.method === "POST") { + const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); + const profile = { + userId: String(body.user_id || "").trim(), + walletAddress: String(body.wallet_address || "").trim(), + jurisdiction: String(body.jurisdiction || "").trim(), + depositLimit: Number(body.deposit_limit || 0), + withdrawReserve: Number(body.withdraw_reserve || 0), + agentPermissions: body.agent_permissions && typeof body.agent_permissions === "object" ? body.agent_permissions : {}, + }; + const errors = validateProfile(profile); + if (errors.length) return res.status(400).json({ ok: false, error: "Invalid risk profile", details: errors }); + + const saved = await upsertRiskProfile(profile); + await recordAuditEvent("RISK_PROFILE_UPDATED", { + user_id: profile.userId, + jurisdiction: profile.jurisdiction, + agent_count: Object.keys(profile.agentPermissions).length, + }); + + return res.status(200).json({ ok: true, saved, live_trading_enabled: false }); + } + + res.setHeader("Allow", "GET, POST"); + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } catch (err) { + return res.status(500).json({ ok: false, error: err?.message || "Risk profile request failed" }); + } +} diff --git a/index.html b/index.html index ec65d5b..26fed1b 100644 --- a/index.html +++ b/index.html @@ -602,6 +602,25 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color

Provider webhooks

events are stored in Neon
Checking webhook setup...
+
+
+

Policies & consent

required before deposits
+
Checking policy status...
+ +
+
+

Server controls

live safety gates
+
+ Production switch remains off +
The backend can now store policies, consent, risk profiles, webhooks, incidents, and dry-run order intents. Real deposits and live orders still require final approval and real CLOB/deposit-wallet setup.
+
+
+
@@ -687,6 +706,7 @@ let PAPER_SEARCH_RESULTS = null; let PAPER_MARKET_OFFSET = 0; let PAPER_MARKET_QUERY = ""; let LIVE_BACKEND_STATUS = null; +let LIVE_POLICY_STATUS = null; let PROVIDER_CONFIG = null; const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other; const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All"; @@ -2347,18 +2367,24 @@ function saveLiveState(s,detail){ } async function fetchLiveBackendStatus(){ try{ - const r=await fetch("/api/live",{cache:"no-store"}); - LIVE_BACKEND_STATUS=await r.json(); + const [liveRes,policyRes]=await Promise.all([ + fetch("/api/live",{cache:"no-store"}), + fetch("/api/policies",{cache:"no-store"}), + ]); + LIVE_BACKEND_STATUS=await liveRes.json(); + LIVE_POLICY_STATUS=await policyRes.json(); }catch(e){ LIVE_BACKEND_STATUS={ok:false,locked_reason:"Live backend status endpoint is unavailable.",providers:[]}; + LIVE_POLICY_STATUS={ok:false,note:"Policy status endpoint is unavailable."}; } renderLiveBackendStatus(); } function renderLiveBackendStatus(){ const root=$("liveBackendStatus");if(!root)return; const hookRoot=$("liveWebhookStatus"); + const policyRoot=$("livePolicyStatus"); const s=LIVE_BACKEND_STATUS; - if(!s){root.innerHTML="Checking backend readiness...";if(hookRoot)hookRoot.innerHTML="Checking webhook setup...";return;} + if(!s){root.innerHTML="Checking backend readiness...";if(hookRoot)hookRoot.innerHTML="Checking webhook setup...";if(policyRoot)policyRoot.innerHTML="Checking policy status...";return;} const providers=(s.providers||[]).map(p=>`
${esc(p.label)}${p.expected?`
${esc(p.expected)}
`:""}
${p.configured?"configured":"missing"}
`).join(""); const stack=(s.provider_stack||[]).map(p=>`
${esc(p.provider)}
${esc(p.role)}
${(p.checks||[]).map(c=>`${esc(c.key)}: ${c.configured?"ok":"missing"}`).join(" ยท ")}
${p.configured?"ready":"needs keys"}
`).join(""); const next=(s.next_required||[]).slice(0,12).map(x=>`
  • ${esc(x)}
  • `).join(""); @@ -2368,6 +2394,12 @@ function renderLiveBackendStatus(){ const hooks=(s.webhooks||[]).map(h=>`
    ${esc(h.provider)}
    ${esc(h.label)}
    ${esc(h.url)}
    `).join(""); hookRoot.innerHTML=hooks||"Webhook URLs will appear after backend readiness loads."; } + if(policyRoot){ + const p=LIVE_POLICY_STATUS||{}; + const versions=p.versions||{}; + const rules=(p.rules||[]).map(rule=>`
  • ${esc(rule)}
  • `).join(""); + policyRoot.innerHTML=`${p.approved?"Policy versions configured":"Policy documents not approved yet"}
    ${esc(p.note||"")}
    Terms${esc(versions.terms||"missing")}
    Privacy${esc(versions.privacy||"missing")}
    Risk disclosure${esc(versions.risk_disclosure||"missing")}
    ${rules?`
      ${rules}
    `:""}`; + } } async function dryRunLiveOrderIntent(){ const s=loadLiveState(); @@ -2389,6 +2421,38 @@ async function dryRunLiveOrderIntent(){ toast("Order dry-run failed."); } } +async function saveRiskProfileToServer(s){ + const body={ + user_id:"local-readiness-user", + wallet_address:s.walletAddress, + jurisdiction:s.jurisdiction, + deposit_limit:s.depositLimit, + withdraw_reserve:s.withdrawReserve, + agent_permissions:s.agents, + }; + const r=await fetch("/api/risk-profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); + const d=await r.json(); + if(!r.ok)throw new Error(d.error||"Risk profile save failed"); + return d; +} +async function recordLiveConsent(){ + const s=loadLiveState(); + try{ + const r=await fetch("/api/consent",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ + user_id:"local-readiness-user", + jurisdiction:s.jurisdiction, + accept_terms:$("liveAcceptTerms").checked, + accept_privacy:$("liveAcceptPrivacy").checked, + accept_risk:$("liveAcceptRisk").checked, + })}); + const d=await r.json(); + if(!r.ok)throw new Error(`${d.error}: ${(d.missing||[]).join(", ")}`); + saveLiveState(s,"Recorded live-money consent versions"); + renderLiveMoneyTab();toast("Consent recorded."); + }catch(e){ + toast(e.message||"Consent is not ready to record yet."); + } +} function renderLiveMoneyTab(){ const root=$("liveChecklist");if(!root)return; const s=loadLiveState(),done=LIVE_CHECKS.filter(([k])=>s.checks[k]).length; @@ -2530,16 +2594,23 @@ $("paperSignOutBtn").addEventListener("click",()=>{ sessionStorage.removeItem(paperSessionKey(acct.id)); renderPaperTab();toast("Signed out of paper account."); }); -$("saveLiveProfileBtn").addEventListener("click",()=>{ +$("saveLiveProfileBtn").addEventListener("click",async()=>{ const s=loadLiveState(); s.walletAddress=$("liveWalletAddress").value.trim(); s.jurisdiction=$("liveJurisdiction").value; s.depositLimit=Number($("liveDepositLimit").value||0); s.withdrawReserve=Number($("liveWithdrawReserve").value||0); - saveLiveState(s,"Updated live-money wallet/funds profile"); - renderLiveMoneyTab();toast("Live-money readiness profile saved."); + try{ + await saveRiskProfileToServer(s); + saveLiveState(s,"Updated live-money wallet/funds profile and server risk profile"); + renderLiveMoneyTab();toast("Live-money readiness profile saved to Neon."); + }catch(e){ + saveLiveState(s,"Updated local live-money wallet/funds profile"); + renderLiveMoneyTab();toast(e.message||"Saved locally, but server risk profile failed."); + } }); $("dryRunOrderBtn").addEventListener("click",()=>dryRunLiveOrderIntent()); +$("recordConsentBtn").addEventListener("click",()=>recordLiveConsent()); window.addEventListener("focus",()=>refreshFromCloudAndRender(true)); document.addEventListener("visibilitychange",()=>{ if(!document.hidden)refreshFromCloudAndRender(true);