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
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.