mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-27 16:07:46 +00:00
Add live money readiness controls
This commit is contained in:
+101
@@ -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();
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user