diff --git a/extension/background.js b/extension/background.js index 67c96b3..3e9dfc1 100644 --- a/extension/background.js +++ b/extension/background.js @@ -450,6 +450,171 @@ async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleM }; } +// ── Topstep: which passed evaluations can still be activated? ──────────────── +// +// "Activatable" is decided against the Topstep dashboard's own GraphQL API +// (crystal.topstep.com), not by scraping the page. An account can be activated +// into an Express Funded Account when it has PASSED its evaluation and has not +// already been activated. Passing shows as status "Passed"; activation spawns a +// funded account (express-funded, or later live-funded) that carries the SAME +// subscription id as the eval it came from. So: a Passed eval whose subscription +// id is not shared by ANY funded account it ever spawned is one you can still +// activate. +// +// The funded account's CURRENT status is irrelevant — activation is one-way, so +// an eval whose Express account has since gone Closed/Violation is still spent, +// not activatable. Filtering funded accounts to "live only" was an early bug: it +// mislabelled long-since-activated-then-closed evals as activatable. Verified +// against the dashboard's own "Activate Express Funded Account" button, which is +// enabled for exactly the evals with no funded account of any status. + +const TOPSTEP_PATTERN = 'https://*.topstep.com/*'; +// The dashboard host is where the app (and its auth) lives; app.topstep.com just +// redirects here. Landing on the accounts route guarantees the account queries +// run, which is how we get hold of the bearer token below. +const TOPSTEP_URL = 'https://dashboard.topstep.com/dashboard/accounts'; + +/** Runs in the page's MAIN world on the Topstep dashboard. Returns the set of + * passed evaluations that can still be activated. + * + * Self-contained: nothing from this file's scope exists inside it. + * + * The API is authenticated with a bearer token the dashboard keeps out of + * reach of injected code, so rather than dig it out we borrow it: hook fetch, + * nudge the app to re-run its own queries, and lift the Authorization header + * off the first request that carries one. Once we have it we call the same + * GraphQL endpoint the dashboard uses. */ +async function pageTopstepScan() { + if (/passwordless|login|sign-?in/i.test(location.pathname) + || document.querySelector('input[type="password"]')) { + return { signedOut: true, url: location.href }; + } + + // Borrow the bearer token from the app's own traffic. + const auth = await new Promise((resolve) => { + if (window.__afTsxAuth) return resolve(window.__afTsxAuth); + const orig = window.fetch; + let settled = false; + const finish = (token) => { + if (settled) return; + settled = true; + window.fetch = orig; + resolve(token); + }; + window.fetch = function (...args) { + try { + const h = args[1] && args[1].headers; + let a = null; + if (h) a = typeof h.get === 'function' ? h.get('authorization') : (h.authorization || h.Authorization); + if (a && /^bearer /i.test(a)) { + window.__afTsxAuth = a; + finish(a); + } + } catch { /* keep going */ } + return orig.apply(this, args); + }; + // Make the app issue an authenticated request so the hook has something + // to read. Its Apollo client re-runs every active query on demand. + try { window.__APOLLO_CLIENT__?.reFetchObservableQueries?.(); } catch { /* ignore */ } + setTimeout(() => finish(null), 8000); + }); + + if (!auth) { + return { error: 'Could not read the Topstep session token — open the Topstep dashboard, sign in, and try again.' }; + } + + const ENDPOINT = 'https://crystal.topstep.com/graphql/TraderAccounts'; + const QUERY = `query AfActivatableScan($filter: AccountFilter) { + accounts(first: 500, filter: $filter) { + nodes { rowId accountName status stage nmiSubscriptionId subscriptionId accountLabels } + } + }`; + const run = async (filter) => { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: auth }, + body: JSON.stringify({ operationName: 'AfActivatableScan', query: QUERY, variables: { filter } }), + }); + if (!res.ok) throw new Error(`Topstep API HTTP ${res.status}`); + const body = await res.json(); + if (body.errors) throw new Error(body.errors.map((e) => e.message).join('; ')); + return body.data.accounts.nodes; + }; + + try { + // Every passed evaluation, and every funded account of ANY status — an + // activation that has already happened, whatever became of the funded + // account since (still Active, or long since Closed/Violation). Status is + // deliberately not filtered: activation is one-way, so a spent eval stays + // spent even after its Express account closes. + const [passed, funded] = await Promise.all([ + run({ status: { equalTo: 'Passed' } }), + run({ stage: { in: ['express-funded', 'live-funded'] } }), + ]); + + const claimedNmi = new Set(funded.map((f) => f.nmiSubscriptionId).filter(Boolean)); + const claimedSub = new Set(funded.map((f) => f.subscriptionId).filter(Boolean)); + + const activatable = []; + let alreadyActivated = 0; + for (const acc of passed) { + const claimed = (acc.nmiSubscriptionId && claimedNmi.has(acc.nmiSubscriptionId)) + || (acc.subscriptionId && claimedSub.has(acc.subscriptionId)); + if (claimed) { alreadyActivated++; continue; } + activatable.push({ + id: acc.rowId, + name: acc.accountName, + type: acc.accountLabels?.accountType || null, + }); + } + activatable.sort((a, b) => a.name.localeCompare(b.name)); + + return { + activatable, + activatableCount: activatable.length, + alreadyActivated, + passedTotal: passed.length, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Scan Topstep for activatable accounts. Reuses an open dashboard tab; opens + * one in the background otherwise. Never raises the window — this is a read, + * and stealing focus for it would be rude. */ +async function checkTopstep() { + const tabs = await chrome.tabs.query({ url: TOPSTEP_PATTERN }); + let tab = tabs.find((t) => /^https?:/.test(t.url || '')); + let opened = false; + if (!tab) { + tab = await chrome.tabs.create({ url: TOPSTEP_URL, active: false }); + opened = true; + await waitForTabLoad(tab.id); + } + + // `complete` only means the document loaded — the SPA still has to mount, + // authenticate and expose its Apollo client before the scan can borrow a + // token. Retry across a budget rather than declaring "nothing" on the first + // miss; a signed-out result or a real answer both end it early. + const deadline = Date.now() + (opened ? 20000 : 10000); + let out = null; + for (;;) { + const [injection] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: 'MAIN', + func: pageTopstepScan, + }); + out = injection?.result || out; + if (out && (out.signedOut || out.error || typeof out.activatableCount === 'number')) break; + if (Date.now() >= deadline) break; + await new Promise((r) => setTimeout(r, 1000)); + } + if (!out) throw new Error('Topstep scan returned nothing'); + + return { ...out, openedTab: opened, checkedAt: Date.now() }; +} + // ── Captcha: read the widget, then hand a solved token back to the page ───── /** Runs in the page's MAIN world. Describes the captcha widget well enough for @@ -1142,12 +1307,44 @@ chrome.storage.onChanged.addListener((changes, area) => { }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type !== 'capture-now') return; - getConfig() - .then(captureAndSend) - .then(() => sendResponse({ ok: true })) - .catch((err) => sendResponse({ ok: false, error: err.message })); - return true; // keep the message channel open for the async response + if (msg?.type === 'capture-now') { + getConfig() + .then(captureAndSend) + .then(() => sendResponse({ ok: true })) + .catch((err) => sendResponse({ ok: false, error: err.message })); + return true; // keep the message channel open for the async response + } + + if (msg?.type === 'topstep-check') { + // Fire-and-forget: a scan can run ~20s (open a tab, wait for the SPA to + // mount, poll for a token), which is longer than a popup or an MV3 + // service worker reliably lives. Relying on sendResponse across that gap + // is what produced "message port closed before a response was received": + // the popup closes, and the reply lands on a dead port. So the answer + // goes to chrome.storage instead — durable, and the popup renders it from + // there whether or not it was still open when the scan finished. + runTopstepCheck(); + // ACK immediately on the live port so the sender's callback resolves now, + // long before the scan does. + sendResponse({ ok: true, started: true }); + return false; + } }); +/** Run a Topstep scan and mirror its lifecycle into storage for the popup: + * a `running` marker up front, then the result (or error). Never throws. */ +async function runTopstepCheck() { + await setTopstepState({ running: true, error: null, result: null }); + try { + const result = await checkTopstep(); + await setTopstepState({ running: false, error: null, result }); + } catch (err) { + await setTopstepState({ running: false, error: err instanceof Error ? err.message : String(err), result: null }); + } +} + +async function setTopstepState(patch) { + await chrome.storage.local.set({ topstepScan: { ...patch, updatedAt: Date.now() } }); +} + restart(); diff --git a/extension/manifest.json b/extension/manifest.json index ee8690c..7c0d265 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.14.0", + "version": "0.15.0", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "update_url": "https://ext.juicerroom.com/updates.xml", @@ -11,6 +11,7 @@ "host_permissions": [ "https://*.tradeify.co/*", "https://*.lucidtrading.com/*", + "https://*.topstep.com/*", "http://localhost/*", "http://127.0.0.1/*" ], diff --git a/extension/popup.html b/extension/popup.html index b9c758e..c28b9f7 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -15,6 +15,15 @@ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .muted { color: #64748b; font-size: 11px; } .err { color: #dc2626; font-size: 11px; margin-top: 4px; word-break: break-word; } + hr { border: 0; border-top: 1px solid #e2e8f0; margin: 14px 0 4px; } + .section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #64748b; margin: 10px 0 6px; } + button.wide { width: 100%; margin-left: 0; } + #tsResult { margin-top: 10px; font-size: 12px; } + #tsResult .big { font-size: 22px; font-weight: 600; color: #0f172a; } + #tsResult ul { margin: 8px 0 0; padding-left: 16px; max-height: 160px; overflow-y: auto; } + #tsResult li { font: 11px ui-monospace, monospace; color: #334155; margin-bottom: 2px; } + #tsResult li a { color: #2563eb; text-decoration: none; } + #tsResult li a:hover { text-decoration: underline; } @@ -33,6 +42,11 @@ +
+
Topstep accounts
+ +
+ diff --git a/extension/popup.js b/extension/popup.js index ec9a04d..4637a2c 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -45,6 +45,64 @@ document.getElementById('now').addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'capture-now' }, () => setTimeout(render, 200)); }); +document.getElementById('tsCheck').addEventListener('click', () => { + // Kick off the scan and return. The worker writes progress and the result to + // storage; renderTopstepState (below) reflects whatever is there, so the + // popup can be closed and reopened mid-scan without losing the answer. The + // callback only acknowledges the request was received — it carries no result. + chrome.runtime.sendMessage({ type: 'topstep-check' }, () => void chrome.runtime.lastError); +}); + +/** Paint the Topstep panel from the stored scan state. */ +function renderTopstepState(state) { + const btn = document.getElementById('tsCheck'); + const out = document.getElementById('tsResult'); + if (!state) { out.innerHTML = ''; return; } + + if (state.running) { + btn.disabled = true; + btn.textContent = 'Checking…'; + out.innerHTML = '
Scanning the Topstep dashboard…
'; + return; + } + + btn.disabled = false; + btn.textContent = 'Check activatable accounts'; + + if (state.error) { + out.innerHTML = `
${esc(state.error)}
`; + return; + } + const r = state.result; + if (!r) { out.innerHTML = ''; return; } + if (r.signedOut) { + out.innerHTML = '
Not signed in to Topstep. Open the dashboard, sign in, then check again.
'; + return; + } + if (r.error) { + out.innerHTML = `
${esc(r.error)}
`; + return; + } + + const n = r.activatableCount || 0; + const acctUrl = (id) => `https://dashboard.topstep.com/dashboard/accounts/${encodeURIComponent(id)}`; + const list = (r.activatable || []) + .map((a) => `
  • ${esc(a.name)}${a.type ? ` · ${esc(a.type)}` : ''}
  • `) + .join(''); + const when = r.checkedAt ? ` · ${new Date(r.checkedAt).toLocaleTimeString()}` : ''; + out.innerHTML = + `
    ${n} activatable
    ` + + `
    ${r.alreadyActivated || 0} already activated · ${r.passedTotal || 0} passed total${when}
    ` + + (list ? `` : '
    No passed accounts waiting on activation.
    '); +} + +// Render whatever the last scan left behind when the popup opens… +chrome.storage.local.get('topstepScan').then(({ topstepScan }) => renderTopstepState(topstepScan)); +// …and keep up with a scan that finishes while the popup is open. +chrome.storage.onChanged.addListener((c, area) => { + if (area === 'local' && 'topstepScan' in c) renderTopstepState(c.topstepScan.newValue); +}); + chrome.storage.onChanged.addListener((c, area) => { if (area === 'local' && 'state' in c) render(); }); function esc(s) {