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:
@@ -36,6 +36,7 @@ TERMS_VERSION=
|
|||||||
PRIVACY_VERSION=
|
PRIVACY_VERSION=
|
||||||
RISK_DISCLOSURE_VERSION=
|
RISK_DISCLOSURE_VERSION=
|
||||||
MARKET_POLICY_STORE=
|
MARKET_POLICY_STORE=
|
||||||
|
CONSENT_STORE=
|
||||||
|
|
||||||
# Wallet / deposit-wallet provider
|
# Wallet / deposit-wallet provider
|
||||||
WALLET_PROVIDER=Circle
|
WALLET_PROVIDER=Circle
|
||||||
@@ -74,6 +75,7 @@ SENTRY_ORG=
|
|||||||
SENTRY_PROJECT=
|
SENTRY_PROJECT=
|
||||||
SENTRY_WEBHOOK_URL=
|
SENTRY_WEBHOOK_URL=
|
||||||
INCIDENT_WEBHOOK_URL=
|
INCIDENT_WEBHOOK_URL=
|
||||||
|
RISK_ADMIN_TOKEN=
|
||||||
ADMIN_ALERT_EMAIL=
|
ADMIN_ALERT_EMAIL=
|
||||||
CUSTOMER_SUPPORT_EMAIL=
|
CUSTOMER_SUPPORT_EMAIL=
|
||||||
RISK_ADMIN_WALLET=
|
RISK_ADMIN_WALLET=
|
||||||
|
|||||||
@@ -96,5 +96,15 @@ Provider event counts are visible at
|
|||||||
- Live Polymarket order placement.
|
- Live Polymarket order placement.
|
||||||
- Automatic agent trading with real funds.
|
- 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
|
Those should stay locked until legal review, eligibility checks, signed-order
|
||||||
execution, reconciliation, audit logs, and incident response are complete.
|
execution, reconciliation, audit logs, and incident response are complete.
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ interfaces visible without moving funds or placing orders.
|
|||||||
- `/api/webhooks/clerk`, `/api/webhooks/veriff`, and `/api/webhooks/circle`
|
- `/api/webhooks/clerk`, `/api/webhooks/veriff`, and `/api/webhooks/circle`
|
||||||
receive provider events and store them in Neon.
|
receive provider events and store them in Neon.
|
||||||
- `/api/provider-events` reports provider event counts for a quick health check.
|
- `/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
|
- 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.
|
limits, provider webhook URLs, a dry-run order console, and an audit preview.
|
||||||
|
|
||||||
|
|||||||
+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;
|
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() {
|
export async function providerEventSummary() {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const db = sql();
|
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 = [
|
const REQUIRED_ENV = [
|
||||||
["PRODUCTION_APP_URL", "Production app URL"],
|
["PRODUCTION_APP_URL", "Production app URL"],
|
||||||
["AUTH_PROVIDER", "Real account authentication provider"],
|
["AUTH_PROVIDER", "Real account authentication provider"],
|
||||||
@@ -16,6 +18,7 @@ const REQUIRED_ENV = [
|
|||||||
["PRIVACY_VERSION", "Approved privacy policy version"],
|
["PRIVACY_VERSION", "Approved privacy policy version"],
|
||||||
["RISK_DISCLOSURE_VERSION", "Approved risk disclosure version"],
|
["RISK_DISCLOSURE_VERSION", "Approved risk disclosure version"],
|
||||||
["MARKET_POLICY_STORE", "Market eligibility and category policy store"],
|
["MARKET_POLICY_STORE", "Market eligibility and category policy store"],
|
||||||
|
["CONSENT_STORE", "Versioned user consent store"],
|
||||||
["PAYMENTS_PROVIDER", "Deposit and withdrawal provider"],
|
["PAYMENTS_PROVIDER", "Deposit and withdrawal provider"],
|
||||||
["PAYMENTS_API_KEY", "Payments provider API key"],
|
["PAYMENTS_API_KEY", "Payments provider API key"],
|
||||||
["PAYMENTS_WEBHOOK_SECRET", "Payments webhook secret"],
|
["PAYMENTS_WEBHOOK_SECRET", "Payments webhook secret"],
|
||||||
@@ -36,6 +39,7 @@ const REQUIRED_ENV = [
|
|||||||
["AUDIT_LOG_STORE", "Durable audit log store"],
|
["AUDIT_LOG_STORE", "Durable audit log store"],
|
||||||
["MONITORING_DSN", "Error and performance monitoring"],
|
["MONITORING_DSN", "Error and performance monitoring"],
|
||||||
["INCIDENT_WEBHOOK_URL", "Incident alert webhook"],
|
["INCIDENT_WEBHOOK_URL", "Incident alert webhook"],
|
||||||
|
["RISK_ADMIN_TOKEN", "Risk admin incident-control token"],
|
||||||
["ADMIN_ALERT_EMAIL", "Admin alert destination"],
|
["ADMIN_ALERT_EMAIL", "Admin alert destination"],
|
||||||
["CUSTOMER_SUPPORT_EMAIL", "Customer support contact"],
|
["CUSTOMER_SUPPORT_EMAIL", "Customer support contact"],
|
||||||
];
|
];
|
||||||
@@ -54,6 +58,7 @@ const ENV_ALIASES = {
|
|||||||
ACCOUNTING_EXPORT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
ACCOUNTING_EXPORT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
||||||
RATE_LIMIT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
RATE_LIMIT_STORE: ["DATABASE_URL", "NEON_DATABASE_URL"],
|
||||||
AUDIT_LOG_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"],
|
MONITORING_DSN: ["SENTRY_DSN"],
|
||||||
INCIDENT_WEBHOOK_URL: ["SENTRY_WEBHOOK_URL"],
|
INCIDENT_WEBHOOK_URL: ["SENTRY_WEBHOOK_URL"],
|
||||||
};
|
};
|
||||||
@@ -223,6 +228,7 @@ export default async function handler(req, res) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!status.live_trading_enabled) {
|
if (!status.live_trading_enabled) {
|
||||||
|
await recordAuditEvent("ORDER_INTENT_BLOCKED", auditEvent);
|
||||||
return res.status(423).json({
|
return res.status(423).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
dry_run: true,
|
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({
|
return res.status(501).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
dry_run: true,
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-6
@@ -602,6 +602,25 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
|||||||
<div class="card-h"><h3>Provider webhooks</h3><span class="small muted">events are stored in Neon</span></div>
|
<div class="card-h"><h3>Provider webhooks</h3><span class="small muted">events are stored in Neon</span></div>
|
||||||
<div id="liveWebhookStatus" class="small muted">Checking webhook setup...</div>
|
<div id="liveWebhookStatus" class="small muted">Checking webhook setup...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h3>Policies & consent</h3><span class="small muted">required before deposits</span></div>
|
||||||
|
<div id="livePolicyStatus" class="locked-panel">Checking policy status...</div>
|
||||||
|
<div class="account-row">
|
||||||
|
<label class="small muted"><input type="checkbox" id="liveAcceptTerms"> terms</label>
|
||||||
|
<label class="small muted"><input type="checkbox" id="liveAcceptPrivacy"> privacy</label>
|
||||||
|
<label class="small muted"><input type="checkbox" id="liveAcceptRisk"> risk disclosure</label>
|
||||||
|
<button class="btn" id="recordConsentBtn">Record consent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h3>Server controls</h3><span class="small muted">live safety gates</span></div>
|
||||||
|
<div class="locked-panel">
|
||||||
|
<b>Production switch remains off</b>
|
||||||
|
<div class="small muted">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.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ ABOUT ============ -->
|
<!-- ============ ABOUT ============ -->
|
||||||
@@ -687,6 +706,7 @@ let PAPER_SEARCH_RESULTS = null;
|
|||||||
let PAPER_MARKET_OFFSET = 0;
|
let PAPER_MARKET_OFFSET = 0;
|
||||||
let PAPER_MARKET_QUERY = "";
|
let PAPER_MARKET_QUERY = "";
|
||||||
let LIVE_BACKEND_STATUS = null;
|
let LIVE_BACKEND_STATUS = null;
|
||||||
|
let LIVE_POLICY_STATUS = null;
|
||||||
let PROVIDER_CONFIG = null;
|
let PROVIDER_CONFIG = null;
|
||||||
const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other;
|
const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other;
|
||||||
const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All";
|
const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All";
|
||||||
@@ -2347,18 +2367,24 @@ function saveLiveState(s,detail){
|
|||||||
}
|
}
|
||||||
async function fetchLiveBackendStatus(){
|
async function fetchLiveBackendStatus(){
|
||||||
try{
|
try{
|
||||||
const r=await fetch("/api/live",{cache:"no-store"});
|
const [liveRes,policyRes]=await Promise.all([
|
||||||
LIVE_BACKEND_STATUS=await r.json();
|
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){
|
}catch(e){
|
||||||
LIVE_BACKEND_STATUS={ok:false,locked_reason:"Live backend status endpoint is unavailable.",providers:[]};
|
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();
|
renderLiveBackendStatus();
|
||||||
}
|
}
|
||||||
function renderLiveBackendStatus(){
|
function renderLiveBackendStatus(){
|
||||||
const root=$("liveBackendStatus");if(!root)return;
|
const root=$("liveBackendStatus");if(!root)return;
|
||||||
const hookRoot=$("liveWebhookStatus");
|
const hookRoot=$("liveWebhookStatus");
|
||||||
|
const policyRoot=$("livePolicyStatus");
|
||||||
const s=LIVE_BACKEND_STATUS;
|
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=>`<div class="leader-row"><span>${esc(p.label)}${p.expected?`<div class="small muted">${esc(p.expected)}</div>`:""}</span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"configured":"missing"}</b></div>`).join("");
|
const providers=(s.providers||[]).map(p=>`<div class="leader-row"><span>${esc(p.label)}${p.expected?`<div class="small muted">${esc(p.expected)}</div>`:""}</span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"configured":"missing"}</b></div>`).join("");
|
||||||
const stack=(s.provider_stack||[]).map(p=>`<div class="leader-row"><span><b>${esc(p.provider)}</b><div class="small muted">${esc(p.role)}</div><div class="small muted">${(p.checks||[]).map(c=>`${esc(c.key)}: ${c.configured?"ok":"missing"}`).join(" · ")}</div></span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"ready":"needs keys"}</b></div>`).join("");
|
const stack=(s.provider_stack||[]).map(p=>`<div class="leader-row"><span><b>${esc(p.provider)}</b><div class="small muted">${esc(p.role)}</div><div class="small muted">${(p.checks||[]).map(c=>`${esc(c.key)}: ${c.configured?"ok":"missing"}`).join(" · ")}</div></span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"ready":"needs keys"}</b></div>`).join("");
|
||||||
const next=(s.next_required||[]).slice(0,12).map(x=>`<li>${esc(x)}</li>`).join("");
|
const next=(s.next_required||[]).slice(0,12).map(x=>`<li>${esc(x)}</li>`).join("");
|
||||||
@@ -2368,6 +2394,12 @@ function renderLiveBackendStatus(){
|
|||||||
const hooks=(s.webhooks||[]).map(h=>`<div class="leader-row"><span><b>${esc(h.provider)}</b><div class="small muted">${esc(h.label)}</div></span><a class="market-link" href="${esc(h.url)}" target="_blank" rel="noopener">${esc(h.url)}</a></div>`).join("");
|
const hooks=(s.webhooks||[]).map(h=>`<div class="leader-row"><span><b>${esc(h.provider)}</b><div class="small muted">${esc(h.label)}</div></span><a class="market-link" href="${esc(h.url)}" target="_blank" rel="noopener">${esc(h.url)}</a></div>`).join("");
|
||||||
hookRoot.innerHTML=hooks||"Webhook URLs will appear after backend readiness loads.";
|
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=>`<li>${esc(rule)}</li>`).join("");
|
||||||
|
policyRoot.innerHTML=`<b>${p.approved?"Policy versions configured":"Policy documents not approved yet"}</b><div class="small muted" style="margin:6px 0">${esc(p.note||"")}</div><div class="leader-row"><span>Terms</span><b>${esc(versions.terms||"missing")}</b></div><div class="leader-row"><span>Privacy</span><b>${esc(versions.privacy||"missing")}</b></div><div class="leader-row"><span>Risk disclosure</span><b>${esc(versions.risk_disclosure||"missing")}</b></div>${rules?`<ol class="steps">${rules}</ol>`:""}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function dryRunLiveOrderIntent(){
|
async function dryRunLiveOrderIntent(){
|
||||||
const s=loadLiveState();
|
const s=loadLiveState();
|
||||||
@@ -2389,6 +2421,38 @@ async function dryRunLiveOrderIntent(){
|
|||||||
toast("Order dry-run failed.");
|
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(){
|
function renderLiveMoneyTab(){
|
||||||
const root=$("liveChecklist");if(!root)return;
|
const root=$("liveChecklist");if(!root)return;
|
||||||
const s=loadLiveState(),done=LIVE_CHECKS.filter(([k])=>s.checks[k]).length;
|
const s=loadLiveState(),done=LIVE_CHECKS.filter(([k])=>s.checks[k]).length;
|
||||||
@@ -2530,16 +2594,23 @@ $("paperSignOutBtn").addEventListener("click",()=>{
|
|||||||
sessionStorage.removeItem(paperSessionKey(acct.id));
|
sessionStorage.removeItem(paperSessionKey(acct.id));
|
||||||
renderPaperTab();toast("Signed out of paper account.");
|
renderPaperTab();toast("Signed out of paper account.");
|
||||||
});
|
});
|
||||||
$("saveLiveProfileBtn").addEventListener("click",()=>{
|
$("saveLiveProfileBtn").addEventListener("click",async()=>{
|
||||||
const s=loadLiveState();
|
const s=loadLiveState();
|
||||||
s.walletAddress=$("liveWalletAddress").value.trim();
|
s.walletAddress=$("liveWalletAddress").value.trim();
|
||||||
s.jurisdiction=$("liveJurisdiction").value;
|
s.jurisdiction=$("liveJurisdiction").value;
|
||||||
s.depositLimit=Number($("liveDepositLimit").value||0);
|
s.depositLimit=Number($("liveDepositLimit").value||0);
|
||||||
s.withdrawReserve=Number($("liveWithdrawReserve").value||0);
|
s.withdrawReserve=Number($("liveWithdrawReserve").value||0);
|
||||||
saveLiveState(s,"Updated live-money wallet/funds profile");
|
try{
|
||||||
renderLiveMoneyTab();toast("Live-money readiness profile saved.");
|
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());
|
$("dryRunOrderBtn").addEventListener("click",()=>dryRunLiveOrderIntent());
|
||||||
|
$("recordConsentBtn").addEventListener("click",()=>recordLiveConsent());
|
||||||
window.addEventListener("focus",()=>refreshFromCloudAndRender(true));
|
window.addEventListener("focus",()=>refreshFromCloudAndRender(true));
|
||||||
document.addEventListener("visibilitychange",()=>{
|
document.addEventListener("visibilitychange",()=>{
|
||||||
if(!document.hidden)refreshFromCloudAndRender(true);
|
if(!document.hidden)refreshFromCloudAndRender(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user