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
+2
View File
@@ -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=
+10
View File
@@ -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.
+7
View File
@@ -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
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; 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();
+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 = [ 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,
+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" });
}
}
+77 -6
View File
@@ -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);