Add cloud paper accounts
This commit is contained in:
@@ -12,7 +12,9 @@ LIVE_TRADING_ENABLED=false
|
||||
AUTH_PROVIDER=
|
||||
DATABASE_URL=
|
||||
SESSION_SECRET=
|
||||
ACCOUNT_SESSION_SECRET=
|
||||
ENCRYPTION_KEY=
|
||||
PMA_ACCOUNTS_INDEX_PATH=accounts/index.json
|
||||
|
||||
# Eligibility / compliance provider
|
||||
KYC_PROVIDER=
|
||||
|
||||
@@ -13,6 +13,10 @@ The site fetches live Polymarket markets, generates agent suggestions, lets you
|
||||
run hourly paper cycles, and syncs the shared arena state through the Vercel API
|
||||
when `BLOB_READ_WRITE_TOKEN` is configured.
|
||||
|
||||
Paper accounts created with a password are also saved through the backend, so a
|
||||
user can log in from another device and see the same paper portfolio, activity,
|
||||
and value history. Passwordless paper accounts remain local-only.
|
||||
|
||||
## Put it online (free) so you can reach it from any device
|
||||
|
||||
Pick one — all give you a public URL:
|
||||
@@ -36,6 +40,9 @@ Pick one — all give you a public URL:
|
||||
Use `.env.example` as the setup template.
|
||||
|
||||
- `BLOB_READ_WRITE_TOKEN` enables cross-device shared state.
|
||||
- `ACCOUNT_SESSION_SECRET` signs cloud paper-account sessions. If omitted, the
|
||||
app falls back to the existing server secret/token, but production should use
|
||||
a dedicated value.
|
||||
- `/api/live` reports whether KYC, payments, wallet/deposit-wallet, Polymarket
|
||||
CLOB, authentication, geofencing, sanctions, audit, support, and monitoring
|
||||
providers are configured.
|
||||
@@ -47,5 +54,6 @@ Use `.env.example` as the setup template.
|
||||
## Notes
|
||||
- Paper trading only right now — no real money, nothing places real orders.
|
||||
- The analysis is a transparent heuristic, **not financial advice**.
|
||||
- The shared arena uses cloud state when configured. Individual paper accounts
|
||||
still use local browser storage unless a backend account database is added.
|
||||
- The shared arena uses cloud state when configured. Password-backed paper
|
||||
accounts use the backend account API; passwordless paper accounts use local
|
||||
browser storage.
|
||||
|
||||
@@ -9,6 +9,8 @@ interfaces visible without moving funds or placing orders.
|
||||
- `.env.example` lists the environment variables the production app needs.
|
||||
- `/api/live` reports which live-money providers are configured or missing.
|
||||
- `/api/live` accepts order intents only as dry-runs and returns an audit event.
|
||||
- `/api/accounts` creates, logs into, and saves password-backed paper accounts
|
||||
through Vercel Blob storage.
|
||||
- The Live Money tab shows launch checks, wallet/deposit settings, agent risk
|
||||
limits, a dry-run order console, and an audit preview.
|
||||
|
||||
@@ -27,8 +29,10 @@ provider, wallet-signing, reconciliation, and monitoring steps below are done.
|
||||
version.
|
||||
|
||||
2. Account and wallet
|
||||
- Real authentication.
|
||||
- Production account database.
|
||||
- Real authentication for live-money users.
|
||||
- Production account database. The current backend account API is enough for
|
||||
paper portfolios, but live money should use a real database with admin,
|
||||
audit, and compliance tooling.
|
||||
- Password reset, sign-out, session expiry, and optional MFA path.
|
||||
- Non-custodial wallet connection or Polymarket deposit-wallet flow.
|
||||
- User-controlled permissions for each agent.
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { get, put } from "@vercel/blob";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const INDEX_PATH = process.env.PMA_ACCOUNTS_INDEX_PATH || "accounts/index.json";
|
||||
const PBKDF2_ITERATIONS = 210000;
|
||||
|
||||
function configured() {
|
||||
return Boolean(process.env.BLOB_READ_WRITE_TOKEN || process.env.VERCEL_OIDC_TOKEN);
|
||||
}
|
||||
|
||||
async function readJson(path, fallback) {
|
||||
const blob = await get(path, { access: "private" });
|
||||
if (!blob || blob.statusCode !== 200 || !blob.stream) return fallback;
|
||||
const text = await new Response(blob.stream).text();
|
||||
return text ? JSON.parse(text) : fallback;
|
||||
}
|
||||
|
||||
async function writeJson(path, data) {
|
||||
await put(path, JSON.stringify(data), {
|
||||
access: "private",
|
||||
allowOverwrite: true,
|
||||
contentType: "application/json",
|
||||
cacheControlMaxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeName(name) {
|
||||
return String(name || "").trim().replace(/\s+/g, " ").slice(0, 48);
|
||||
}
|
||||
|
||||
function accountKey(name) {
|
||||
return normalizeName(name).toLowerCase();
|
||||
}
|
||||
|
||||
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
|
||||
const hash = crypto.pbkdf2Sync(String(password), salt, PBKDF2_ITERATIONS, 32, "sha256").toString("hex");
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(a, b) {
|
||||
const ab = Buffer.from(String(a || ""), "hex");
|
||||
const bb = Buffer.from(String(b || ""), "hex");
|
||||
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function tokenSecret() {
|
||||
return process.env.ACCOUNT_SESSION_SECRET || process.env.SESSION_SECRET || process.env.BLOB_READ_WRITE_TOKEN || "local-dev-only";
|
||||
}
|
||||
|
||||
function makeSessionToken(accountId) {
|
||||
const issued = Date.now();
|
||||
const payload = `${accountId}.${issued}`;
|
||||
const sig = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
function verifySessionToken(accountId, token) {
|
||||
const parts = String(token || "").split(".");
|
||||
if (parts.length !== 3 || parts[0] !== accountId) return false;
|
||||
const issued = Number(parts[1]);
|
||||
if (!Number.isFinite(issued) || Date.now() - issued > 1000 * 60 * 60 * 24 * 30) return false;
|
||||
const payload = `${parts[0]}.${parts[1]}`;
|
||||
const expected = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
|
||||
return timingSafeEqualHex(parts[2], expected);
|
||||
}
|
||||
|
||||
function sanitizeAccount(account) {
|
||||
const clean = account && typeof account === "object" ? JSON.parse(JSON.stringify(account)) : {};
|
||||
clean.id = String(clean.id || "");
|
||||
clean.name = normalizeName(clean.name || "Paper Trader");
|
||||
clean.cash = Number.isFinite(Number(clean.cash)) ? Number(clean.cash) : 10000;
|
||||
clean.starting_balance = Number.isFinite(Number(clean.starting_balance)) ? Number(clean.starting_balance) : 10000;
|
||||
clean.positions = Array.isArray(clean.positions) ? clean.positions.slice(0, 500) : [];
|
||||
clean.history = Array.isArray(clean.history) ? clean.history.slice(-1000) : [];
|
||||
clean.snapshots = Array.isArray(clean.snapshots) ? clean.snapshots.slice(-2000) : [];
|
||||
clean.passwordHash = "cloud";
|
||||
clean.cloudBacked = true;
|
||||
return clean;
|
||||
}
|
||||
|
||||
function accountPath(accountId) {
|
||||
return `accounts/${accountId}.json`;
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
try {
|
||||
if (!configured()) return res.status(503).json({ ok: false, error: "Account storage is not configured" });
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({ ok: false, error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
|
||||
const action = String(body.action || "");
|
||||
const index = await readJson(INDEX_PATH, { version: 1, accounts: {} });
|
||||
|
||||
if (action === "create") {
|
||||
const name = normalizeName(body.name);
|
||||
const password = String(body.password || "");
|
||||
if (name.length < 2) return res.status(400).json({ ok: false, error: "Account name is too short" });
|
||||
if (password.length < 4) return res.status(400).json({ ok: false, error: "Use at least 4 characters for a cloud paper password" });
|
||||
const key = accountKey(name);
|
||||
if (index.accounts[key]) return res.status(409).json({ ok: false, error: "That paper account name already exists" });
|
||||
const id = `acct_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
||||
const passwordRecord = hashPassword(password);
|
||||
const now = new Date().toISOString();
|
||||
index.accounts[key] = { id, name, created_at: now, updated_at: now, password: passwordRecord };
|
||||
const account = sanitizeAccount(body.account || {});
|
||||
account.id = id;
|
||||
account.name = name;
|
||||
account.history = [{ date: now.slice(0, 10), action: "CREATE", detail: "Created cloud-synced paper account." }].concat(account.history || []).slice(-1000);
|
||||
await writeJson(INDEX_PATH, index);
|
||||
await writeJson(accountPath(id), { version: 1, updated_at: now, account });
|
||||
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(id) });
|
||||
}
|
||||
|
||||
if (action === "login") {
|
||||
const key = accountKey(body.name);
|
||||
const record = index.accounts[key];
|
||||
if (!record) return res.status(404).json({ ok: false, error: "Paper account not found" });
|
||||
const passwordRecord = record.password || {};
|
||||
const attempt = hashPassword(String(body.password || ""), passwordRecord.salt);
|
||||
if (!timingSafeEqualHex(attempt.hash, passwordRecord.hash)) return res.status(401).json({ ok: false, error: "Incorrect paper account password" });
|
||||
const saved = await readJson(accountPath(record.id), null);
|
||||
const account = sanitizeAccount(saved && saved.account ? saved.account : {});
|
||||
account.id = record.id;
|
||||
account.name = record.name;
|
||||
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(record.id) });
|
||||
}
|
||||
|
||||
if (action === "save") {
|
||||
const accountId = String(body.account_id || "");
|
||||
if (!verifySessionToken(accountId, body.session_token)) return res.status(401).json({ ok: false, error: "Cloud paper session expired. Log in again." });
|
||||
const account = sanitizeAccount(body.account || {});
|
||||
account.id = accountId;
|
||||
const now = new Date().toISOString();
|
||||
await writeJson(accountPath(accountId), { version: 1, updated_at: now, account });
|
||||
return res.status(200).json({ ok: true, updated_at: now });
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: "Unknown account action" });
|
||||
} catch (err) {
|
||||
const message = err && err.message ? err.message : "Account request failed";
|
||||
return res.status(500).json({ ok: false, error: message });
|
||||
}
|
||||
}
|
||||
+81
-13
@@ -475,7 +475,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
||||
<div class="paper-grid">
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Account</h3><span class="small muted">browser paper money</span></div>
|
||||
<div class="card-h"><h3>Account</h3><span class="small muted">cloud paper money when password-backed</span></div>
|
||||
<div class="account-row">
|
||||
<select class="select" id="paperAccountSelect" aria-label="Paper account"></select>
|
||||
<input class="input" id="paperAccountName" placeholder="New account name" />
|
||||
@@ -501,7 +501,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
|
||||
<input class="input" id="buyAgentAmount" type="number" min="1" step="25" placeholder="Amount" />
|
||||
<button class="btn" id="buyAgentBtn">Buy agent shares</button>
|
||||
</div>
|
||||
<p class="small muted" style="margin:12px 0 0">Deposits and withdrawals are paper-cash controls. Copying an agent replaces this paper account with that agent's current cash and open positions. Buying agent shares lets users invest any paper amount in an agent's return stream.</p>
|
||||
<p class="small muted" style="margin:12px 0 0">Deposits and withdrawals are paper-cash controls. Accounts created with a password sync through the backend so the same portfolio can load on another device. Copying an agent replaces this paper account with that agent's current cash and open positions.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Paper account leaderboard</h3><span class="small muted">best returns</span></div>
|
||||
@@ -662,6 +662,7 @@ const CHART_RANGE_KEY = "pma_chart_range_v1";
|
||||
const INVEST_KEY = "pma_invest_allocations_v1";
|
||||
const PAPER_KEY = "pma_paper_accounts_v1";
|
||||
const LIVE_KEY = "pma_live_readiness_v1";
|
||||
const PAPER_SESSION_PREFIX = "pma_cloud_session_";
|
||||
const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"];
|
||||
const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY];
|
||||
const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff",
|
||||
@@ -718,6 +719,7 @@ const $ = (id) => document.getElementById(id);
|
||||
const fmtUSD = (n) => "$" + Number(n).toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const fmtPct = (n) => (n>=0?"+":"") + Number(n).toFixed(2) + "%";
|
||||
const signClass = (n) => (n>0?"pos-val":n<0?"neg-val":"");
|
||||
const paperSessionKey=(id)=>`${PAPER_SESSION_PREFIX}${id}`;
|
||||
const marketSearchUrl=(q)=>`https://polymarket.com/search?query=${encodeURIComponent(q||"")}`;
|
||||
const positionUrl=(pos)=>pos&&pos.url?pos.url:marketSearchUrl(pos&&pos.question);
|
||||
function partsInLocalTime(d=new Date()){
|
||||
@@ -1913,11 +1915,51 @@ function loadPaperStore(){
|
||||
}
|
||||
function savePaperStore(store,sync=true){
|
||||
localStorage.setItem(PAPER_KEY,JSON.stringify(store));
|
||||
if(sync)pushCloudState();
|
||||
if(sync){pushCloudState();syncActivePaperAccount(store);}
|
||||
}
|
||||
function activePaperAccount(store=loadPaperStore()){
|
||||
return store.accounts[store.activeId]||Object.values(store.accounts)[0];
|
||||
}
|
||||
async function paperAccountRequest(payload){
|
||||
const r=await fetch("/api/accounts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
|
||||
const d=await r.json().catch(()=>({ok:false,error:"Account service returned an invalid response"}));
|
||||
if(!r.ok||!d.ok)throw new Error(d.error||"Paper account request failed");
|
||||
return d;
|
||||
}
|
||||
function installCloudPaperAccount(account,token){
|
||||
const store=loadPaperStore();
|
||||
account.cloudBacked=true;account.passwordHash="cloud";
|
||||
store.accounts[account.id]=account;store.activeId=account.id;
|
||||
sessionStorage.setItem(unlockKey(account.id),"1");
|
||||
sessionStorage.setItem(paperSessionKey(account.id),token);
|
||||
savePaperStore(store,false);
|
||||
return store;
|
||||
}
|
||||
async function createCloudPaperAccount(name,password){
|
||||
const local=defaultPaperAccount(name,password);
|
||||
local.passwordHash="cloud";local.cloudBacked=true;
|
||||
const d=await paperAccountRequest({action:"create",name,password,account:local});
|
||||
installCloudPaperAccount(d.account,d.session_token);
|
||||
}
|
||||
async function loginCloudPaperAccount(name,password){
|
||||
const d=await paperAccountRequest({action:"login",name,password});
|
||||
installCloudPaperAccount(d.account,d.session_token);
|
||||
}
|
||||
let PAPER_SYNC_TIMER=null;
|
||||
function syncActivePaperAccount(store=loadPaperStore()){
|
||||
const acct=activePaperAccount(store);
|
||||
if(!acct||!acct.cloudBacked)return;
|
||||
const token=sessionStorage.getItem(paperSessionKey(acct.id));
|
||||
if(!token)return;
|
||||
clearTimeout(PAPER_SYNC_TIMER);
|
||||
PAPER_SYNC_TIMER=setTimeout(async()=>{
|
||||
try{
|
||||
await paperAccountRequest({action:"save",account_id:acct.id,session_token:token,account:acct});
|
||||
}catch(e){
|
||||
toast(e.message||"Cloud paper save failed.");
|
||||
}
|
||||
},500);
|
||||
}
|
||||
function paperEquity(acct){
|
||||
return acct.cash+(acct.positions||[]).reduce((s,p)=>s+(p.value||p.shares*p.current_price||0),0);
|
||||
}
|
||||
@@ -2100,10 +2142,16 @@ function renderPaperTab(){
|
||||
if(buyAgentSel)buyAgentSel.innerHTML=AGENTS.map(a=>`<option value="${a.id}">${a.emoji} ${esc(a.name)}</option>`).join("");
|
||||
const lock=$("paperLockPanel");
|
||||
if(lock)lock.innerHTML=acct.passwordHash&&!isPaperUnlocked(acct)
|
||||
? `<div class="locked-panel"><b>Password protected.</b><div class="account-row" style="margin:8px 0 0"><input class="input" id="paperUnlockInput" type="password" placeholder="Password"><button class="btn" id="paperUnlockBtn">Unlock</button></div></div>`
|
||||
: acct.passwordHash?`<div class="small muted" style="margin-bottom:10px">Account unlocked for this browser session.</div>`:`<div class="small muted" style="margin-bottom:10px">No password set for this paper account.</div>`;
|
||||
? `<div class="locked-panel"><b>${acct.cloudBacked?"Cloud paper account locked.":"Password protected."}</b><div class="account-row" style="margin:8px 0 0"><input class="input" id="paperUnlockInput" type="password" placeholder="Password"><button class="btn" id="paperUnlockBtn">${acct.cloudBacked?"Cloud log in":"Unlock"}</button></div></div>`
|
||||
: acct.cloudBacked?`<div class="small muted" style="margin-bottom:10px">Cloud-synced paper account unlocked. Trades and portfolio history save across devices.</div>`
|
||||
: acct.passwordHash?`<div class="small muted" style="margin-bottom:10px">Local account unlocked for this browser session.</div>`:`<div class="small muted" style="margin-bottom:10px">Local-only paper account. Add a password when creating an account to sync across devices.</div>`;
|
||||
const unlockBtn=$("paperUnlockBtn");
|
||||
if(unlockBtn)unlockBtn.onclick=()=>{if(requirePaperUnlock(acct,$("paperUnlockInput").value)){renderPaperTab();toast("Paper account unlocked.");}};
|
||||
if(unlockBtn)unlockBtn.onclick=async()=>{
|
||||
if(acct.cloudBacked){
|
||||
try{await loginCloudPaperAccount(acct.name,$("paperUnlockInput").value);renderPaperTab();toast("Cloud paper account loaded.");}
|
||||
catch(e){toast(e.message);}
|
||||
}else if(requirePaperUnlock(acct,$("paperUnlockInput").value)){renderPaperTab();toast("Paper account unlocked.");}
|
||||
};
|
||||
const locked=acct.passwordHash&&!isPaperUnlocked(acct);
|
||||
if(locked){
|
||||
$("paperStats").innerHTML=`<div class="locked-panel" style="grid-column:1/-1">Log in to view this paper portfolio.</div>`;
|
||||
@@ -2376,13 +2424,21 @@ $("resetBtn").addEventListener("click",()=>{
|
||||
if(!confirm("Reset all ten agents to their $10,000 starting balance?"))return;
|
||||
saveState(defaultState());localStorage.removeItem(SUG_KEY);pushCloudState(true);toast("All agents reset.");renderAll();
|
||||
});
|
||||
$("createPaperAccountBtn").addEventListener("click",()=>{
|
||||
$("createPaperAccountBtn").addEventListener("click",async()=>{
|
||||
const name=($("paperAccountName").value||"Paper Trader").trim().slice(0,40);
|
||||
const password=($("paperAccountPassword").value||"").trim();
|
||||
const store=loadPaperStore(),acct=defaultPaperAccount(name||"Paper Trader",password);
|
||||
store.accounts[acct.id]=acct;store.activeId=acct.id;
|
||||
$("paperAccountName").value="";$("paperAccountPassword").value="";
|
||||
savePaperStore(store);renderPaperTab();toast("Paper account created.");
|
||||
try{
|
||||
if(password){
|
||||
await createCloudPaperAccount(name||"Paper Trader",password);
|
||||
toast("Cloud paper account created.");
|
||||
}else{
|
||||
const store=loadPaperStore(),acct=defaultPaperAccount(name||"Paper Trader",password);
|
||||
store.accounts[acct.id]=acct;store.activeId=acct.id;
|
||||
savePaperStore(store);toast("Local paper account created.");
|
||||
}
|
||||
$("paperAccountName").value="";$("paperAccountPassword").value="";
|
||||
renderPaperTab();
|
||||
}catch(e){toast(e.message);}
|
||||
});
|
||||
$("copyAgentBtn").addEventListener("click",()=>{
|
||||
const agentId=$("copyAgentSelect").value,book=cloneAgentBook(agentId),cfg=agentById(agentId);
|
||||
@@ -2400,13 +2456,25 @@ $("paperSearchBtn").addEventListener("click",()=>searchPaperMarkets());
|
||||
$("paperAllBtn").addEventListener("click",()=>{PAPER_MARKET_QUERY="";PAPER_SEARCH_RESULTS=[];loadPaperMarketPage(true);});
|
||||
$("paperMoreBtn").addEventListener("click",()=>loadPaperMarketPage(false));
|
||||
$("paperClearSearchBtn").addEventListener("click",()=>{PAPER_SEARCH_RESULTS=null;PAPER_MARKET_OFFSET=0;PAPER_MARKET_QUERY="";renderPaperTab();});
|
||||
$("paperLoginBtn").addEventListener("click",()=>{
|
||||
$("paperLoginBtn").addEventListener("click",async()=>{
|
||||
const acct=activePaperAccount();
|
||||
if(requirePaperUnlock(acct,$("paperPasswordInput").value)){renderPaperTab();toast("Logged in.");}
|
||||
const cloudName=($("paperAccountName").value||"").trim();
|
||||
const topPassword=($("paperAccountPassword").value||"").trim();
|
||||
try{
|
||||
if(cloudName&&topPassword){
|
||||
await loginCloudPaperAccount(cloudName,topPassword);
|
||||
$("paperAccountName").value="";$("paperAccountPassword").value="";
|
||||
renderPaperTab();toast("Cloud paper account loaded.");
|
||||
}else if(acct.cloudBacked){
|
||||
await loginCloudPaperAccount(acct.name,$("paperPasswordInput").value);
|
||||
renderPaperTab();toast("Cloud paper account loaded.");
|
||||
}else if(requirePaperUnlock(acct,$("paperPasswordInput").value)){renderPaperTab();toast("Logged in.");}
|
||||
}catch(e){toast(e.message);}
|
||||
});
|
||||
$("paperSignOutBtn").addEventListener("click",()=>{
|
||||
const acct=activePaperAccount();
|
||||
sessionStorage.removeItem(unlockKey(acct.id));
|
||||
sessionStorage.removeItem(paperSessionKey(acct.id));
|
||||
renderPaperTab();toast("Signed out of paper account.");
|
||||
});
|
||||
$("saveLiveProfileBtn").addEventListener("click",()=>{
|
||||
|
||||
Reference in New Issue
Block a user