diff --git a/.env.example b/.env.example index 2b1efca..b8983a0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index a1135d2..78d714e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index dfe4fe3..45d3ccd 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -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. diff --git a/api/accounts.js b/api/accounts.js new file mode 100644 index 0000000..dcf6f82 --- /dev/null +++ b/api/accounts.js @@ -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 }); + } +} diff --git a/index.html b/index.html index d89a01e..679caef 100644 --- a/index.html +++ b/index.html @@ -475,7 +475,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
-

Account

browser paper money
+

Account

cloud paper money when password-backed
-

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.

+

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.

Paper account leaderboard

best returns
@@ -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=>``).join(""); const lock=$("paperLockPanel"); if(lock)lock.innerHTML=acct.passwordHash&&!isPaperUnlocked(acct) - ? `
Password protected.
` - : acct.passwordHash?`
Account unlocked for this browser session.
`:`
No password set for this paper account.
`; + ? `
${acct.cloudBacked?"Cloud paper account locked.":"Password protected."}
` + : acct.cloudBacked?`
Cloud-synced paper account unlocked. Trades and portfolio history save across devices.
` + : acct.passwordHash?`
Local account unlocked for this browser session.
`:`
Local-only paper account. Add a password when creating an account to sync across devices.
`; 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=`
Log in to view this paper portfolio.
`; @@ -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",()=>{