Add live money readiness controls

This commit is contained in:
Theodore Song
2026-07-13 16:47:06 -04:00
parent 7dc0bb4dfd
commit 3cc818a13f
10 changed files with 411 additions and 6 deletions
+101
View File
@@ -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();
+52
View File
@@ -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" });
}
}
+48
View File
@@ -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" });
}
}
+7
View File
@@ -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,
+47
View File
@@ -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.",
});
}
+60
View File
@@ -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" });
}
}