From 769d96883fe651cdb8a25e6c78425f927e1ccbc8 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 26 Oct 2026 14:42:01 -0500 Subject: [PATCH] Vest: the relayed login binds itself to an unset Vest firm A Vest firm's credential is the wallet of the login it trades. It no longer has to be typed or clicked in: when the extension relays a Vest tab's session and a Vest firm here has no credential, that wallet is saved as the credential (lib/vest-wallet.ts), the client starts, and the backup is pushed. - A saved wallet is never replaced. When the tab is signed in to another login, the firm now says which one and how to switch: sign in as the saved wallet, or remove the credential. - With two logins relayed at once nothing binds itself; the one-click form decides. - Extension 0.17.1 re-posts the session every minute even when it hasn't changed. The dashboard holds it in memory only, so a restart there was unauthenticated until the tab's next renewal (up to ~15 min); now it is back within a minute, and a firm created later binds within a minute too. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++-- app/api/vest/session/route.ts | 9 ++++++-- app/page.tsx | 10 +++++---- extension/README.md | 5 +++-- extension/background.js | 11 +++++++-- extension/manifest.json | 2 +- lib/vest-class.ts | 7 ++++-- lib/vest-wallet.ts | 42 +++++++++++++++++++++++++++++++++++ 8 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 lib/vest-wallet.ts diff --git a/README.md b/README.md index 5abd619..5d81dcd 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,12 @@ instance cannot log in by itself. Instead: 2. The extension relays the tab's short-lived access token to this dashboard (`POST /api/vest/session`, held in memory only) every time the Vest app renews it. If no Vest tab is open, the extension opens one in the background. -3. On the dashboard, the Vest firm's credential is the **wallet address** of - that login. The credential form offers the relayed wallet with one click. +3. The Vest firm's credential on this dashboard is the **wallet address** of + that login, and it binds itself: the first relayed session fills it in when + the firm has none. A saved wallet is never replaced. If the tab is signed in + to a different login, the firm says so; remove its credential to switch. + With two logins relayed at once, nothing binds by itself, and the credential + form offers each relayed wallet with one click. With no live session for the wallet, the client sits unauthenticated and says so; it never tries to log in. diff --git a/app/api/vest/session/route.ts b/app/api/vest/session/route.ts index e0f89ba..8b5e76b 100644 --- a/app/api/vest/session/route.ts +++ b/app/api/vest/session/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from 'next/server'; import { putVestSession, listVestSessions } from '@/lib/vest-session'; +import { autoBindVestWallet } from '@/lib/vest-wallet'; import { getTradableFirms } from '@/lib/firm-catalog'; import { getClients } from '@/lib/clients'; import { corsJson, corsPreflight } from '../../autobuyer/cors'; @@ -15,7 +16,10 @@ function wantedWallets(): string[] { /** * The extension's Vest relay (lib/vest-session.ts): POST the access token the * signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr, - * userId } — and it is held in memory for this machine's Vest client. GET + * userId } — and it is held in memory for this machine's Vest client. A Vest + * firm here with no credential yet is bound to that login's wallet + * (lib/vest-wallet.ts). The extension posts again every minute, so a restart + * here is back on the session within a minute. GET * lists what has been relayed (never the tokens) and which wallets this * machine wants, so the extension knows whether to keep a Vest tab open and * the dashboard can offer the address as the firm's credential. @@ -38,6 +42,7 @@ export async function POST(req: NextRequest) { }); if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 }); + const bound = autoBindVestWallet(); const wanted = wantedWallets(); const addr = String(body.primaryAddr).toLowerCase(); // A client waiting on this wallet's session syncs now, not at its next poll. @@ -45,7 +50,7 @@ export async function POST(req: NextRequest) { const vest = client as { onVestSession?: (wallet: string) => void }; vest.onVestSession?.(addr); } - return corsJson({ ok: true, used: wanted.includes(addr), wanted }); + return corsJson({ ok: true, used: wanted.includes(addr), wanted, bound }); } export async function GET() { diff --git a/app/page.tsx b/app/page.tsx index 775fe5b..b586e4f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -209,9 +209,11 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo return ; } -/** A Vest firm's credential: the wallet its Vest login belongs to. The - * extension has relayed the session of any Vest tab signed in on this - * machine, so those wallets are offered with one click. */ +/** A Vest firm's credential: the wallet its Vest login belongs to. It binds + * itself as soon as the extension relays a Vest tab signed in on this + * machine (lib/vest-wallet.ts); this form is for when it can't — two logins + * relayed at once, or none yet — and offers the relayed wallets with one + * click. */ function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { const [wallet, setWallet] = useState(''); const [seen, setSeen] = useState([]); @@ -260,7 +262,7 @@ function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo ))} 0 ? 'or another wallet 0x…' : 'Vest wallet 0x… (sign in to Vest in Chrome first)'} + placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Sign in to Vest in Chrome: it binds itself (or paste 0x…)'} value={wallet} onChange={(e) => setWallet(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }} diff --git a/extension/README.md b/extension/README.md index 6eb78b7..cbd9538 100644 --- a/extension/README.md +++ b/extension/README.md @@ -87,8 +87,9 @@ log in to Vest by itself. The extension relays the browser's session instead: ``` vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed -background.js relayVestSession lifts it every 10s, posts it when it changes -dashboard POST /api/vest/session holds it in memory for the firm's Vest client +background.js relayVestSession lifts it every 10s, posts it when it changes (and every minute) +dashboard POST /api/vest/session holds it in memory for the firm's Vest client, + and binds an unset Vest firm to the login's wallet ``` - The hook only reads the Vest app's own `/v3/auth/refresh` and `/register` diff --git a/extension/background.js b/extension/background.js index bd9f023..d446601 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1208,11 +1208,15 @@ async function tick() { const VEST_PATTERN = 'https://next.vestmarkets.com/*'; const VEST_URL = 'https://next.vestmarkets.com/portfolio'; const VEST_EVERY_MS = 10_000; +/** The session is posted again this often even unchanged: the dashboard + * keeps it in memory only, so a restart there needs it back. */ +const VEST_REPOST_MS = 60_000; /** A Vest tab opened for the relay isn't opened again for this long. */ const VEST_REOPEN_MS = 10 * 60_000; let vestLastCheck = 0; let vestLastRelayed = ''; +let vestLastPostAt = 0; let vestOpenedAt = 0; /** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while @@ -1276,7 +1280,7 @@ async function relayVestSession(cfg) { } const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`; - if (fingerprint === vestLastRelayed) return; + if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return; const res = await fetch(`${cfg.apiBase}/api/vest/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1288,9 +1292,12 @@ async function relayVestSession(cfg) { if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay'); if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`); vestLastRelayed = fingerprint; + vestLastPostAt = now; const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`; const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000)); - await setState({ vest: `Relayed ${short}${body.used ? '' : ' (no Vest firm here uses this wallet)'} · good ${mins}m` }); + const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}` + : body.used ? '' : ' (no Vest firm here uses this wallet)'; + await setState({ vest: `Relayed ${short}${note} · good ${mins}m` }); } // ── 2FA: the person's authenticator code for the firm site you are on ─────── diff --git a/extension/manifest.json b/extension/manifest.json index 6a7f136..8f0bb76 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.17.0", + "version": "0.17.1", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "update_url": "https://ext.juicerroom.com/updates.xml", diff --git a/lib/vest-class.ts b/lib/vest-class.ts index fe2208a..7df4007 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -49,7 +49,7 @@ import { firmInstruments } from './instruments'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; -import { vestSessionFor, dropVestSession } from './vest-session'; +import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session'; import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, @@ -231,7 +231,10 @@ export class VestClient implements PlatformClient { private userToken(): string | null { const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS); if (session) return session.accessToken; - const msg = `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`; + const other = listVestSessions().find((s) => s.primaryAddr !== this.wallet && s.expiresAt > Date.now()); + const msg = other + ? `no Vest session for ${this.wallet} — the Vest tab here is signed in as ${other.primaryAddr}; sign in as ${this.wallet} there, or remove this firm's credential on the dashboard to switch to the tab's login` + : `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`; if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`); this.lastAuthError = this.lastFetchErrors['login'] = msg; return null; diff --git a/lib/vest-wallet.ts b/lib/vest-wallet.ts new file mode 100644 index 0000000..ff73b28 --- /dev/null +++ b/lib/vest-wallet.ts @@ -0,0 +1,42 @@ +/** + * Binds the Vest login the extension relays to this machine's Vest firm, so + * nobody has to copy a wallet address into the dashboard. + * + * A Vest firm's only credential here is the wallet of the login whose session + * it trades (lib/vest-session.ts). One person per machine, and Vest allows one + * profile per person, so the Vest tab signed in on this machine is the one to + * trade. It is bound automatically when: + * - the firm has no credential yet. A saved one is never replaced: someone + * chose it, and a different login in the tab is a mistake to flag (the + * client says so), not one to follow; + * - exactly one wallet has a live relayed session. Two logins in two tabs + * can't be told apart, so the dashboard's one-click form decides. + */ + +import { getCachedCatalog } from './firm-catalog'; +import { getCredentials, setCredentials } from './db'; +import { listVestSessions } from './vest-session'; +import { reconcileClients } from './clients'; +import { scheduleBackupPush } from './backup-sync'; + +/** Bind the relayed wallet to every Vest firm here that has no credential. + * Returns the names of the firms it bound. */ +export function autoBindVestWallet(now = Date.now()): string[] { + const live = [...new Set(listVestSessions().filter((s) => s.expiresAt > now).map((s) => s.primaryAddr))]; + if (live.length !== 1) return []; + const wallet = live[0]; + const bound: string[] = []; + for (const firm of getCachedCatalog()) { + if (firm.platform !== 'vest') continue; + const creds = getCredentials(firm.id); + if (creds && (creds.apiKey || creds.username || creds.password)) continue; + setCredentials(firm.id, firm.name, '', '', wallet); + bound.push(firm.name); + console.log(`[vest] ${firm.name}: bound to wallet ${wallet}, the Vest login signed in on this machine`); + } + if (bound.length > 0) { + reconcileClients(); + scheduleBackupPush(); + } + return bound; +}