extension: detect activatable Topstep accounts
Add a popup button that scans the Topstep dashboard's GraphQL API (crystal.topstep.com) for passed evaluations that can still be activated into an Express Funded account. An eval is activatable when its status is "Passed" and its subscription id is not shared by ANY funded account it ever spawned — regardless of that funded account's current status, since activation is one-way. The result and its progress are mirrored to chrome.storage so the popup renders them independent of the message port (avoids the "message port closed" error on this ~20s scan). Activatable accounts link to their Topstep detail page. Adds https://*.topstep.com/* host permission; bumps to 0.15.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1dccd7dec
commit
1bf8ba8693
+198
-1
@@ -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 ─────
|
// ── 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
|
/** 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) => {
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
if (msg?.type !== 'capture-now') return;
|
if (msg?.type === 'capture-now') {
|
||||||
getConfig()
|
getConfig()
|
||||||
.then(captureAndSend)
|
.then(captureAndSend)
|
||||||
.then(() => sendResponse({ ok: true }))
|
.then(() => sendResponse({ ok: true }))
|
||||||
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
||||||
return true; // keep the message channel open for the async response
|
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();
|
restart();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://*.tradeify.co/*",
|
"https://*.tradeify.co/*",
|
||||||
"https://*.lucidtrading.com/*",
|
"https://*.lucidtrading.com/*",
|
||||||
|
"https://*.topstep.com/*",
|
||||||
"http://localhost/*",
|
"http://localhost/*",
|
||||||
"http://127.0.0.1/*"
|
"http://127.0.0.1/*"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||||
.muted { color: #64748b; font-size: 11px; }
|
.muted { color: #64748b; font-size: 11px; }
|
||||||
.err { color: #dc2626; font-size: 11px; margin-top: 4px; word-break: break-word; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -33,6 +42,11 @@
|
|||||||
<button id="save">Save</button>
|
<button id="save">Save</button>
|
||||||
<button id="now" class="ghost">Capture now</button>
|
<button id="now" class="ghost">Capture now</button>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div class="section-title">Topstep accounts</div>
|
||||||
|
<button id="tsCheck" class="wide">Check activatable accounts</button>
|
||||||
|
<div id="tsResult"></div>
|
||||||
|
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -45,6 +45,64 @@ document.getElementById('now').addEventListener('click', () => {
|
|||||||
chrome.runtime.sendMessage({ type: 'capture-now' }, () => setTimeout(render, 200));
|
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 = '<div class="muted">Scanning the Topstep dashboard…</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Check activatable accounts';
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
out.innerHTML = `<div class="err">${esc(state.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = state.result;
|
||||||
|
if (!r) { out.innerHTML = ''; return; }
|
||||||
|
if (r.signedOut) {
|
||||||
|
out.innerHTML = '<div class="err">Not signed in to Topstep. Open the dashboard, sign in, then check again.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.error) {
|
||||||
|
out.innerHTML = `<div class="err">${esc(r.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = r.activatableCount || 0;
|
||||||
|
const acctUrl = (id) => `https://dashboard.topstep.com/dashboard/accounts/${encodeURIComponent(id)}`;
|
||||||
|
const list = (r.activatable || [])
|
||||||
|
.map((a) => `<li><a href="${acctUrl(a.id)}" target="_blank" rel="noopener">${esc(a.name)}</a>${a.type ? ` · ${esc(a.type)}` : ''}</li>`)
|
||||||
|
.join('');
|
||||||
|
const when = r.checkedAt ? ` · ${new Date(r.checkedAt).toLocaleTimeString()}` : '';
|
||||||
|
out.innerHTML =
|
||||||
|
`<div class="big">${n} activatable</div>` +
|
||||||
|
`<div class="muted">${r.alreadyActivated || 0} already activated · ${r.passedTotal || 0} passed total${when}</div>` +
|
||||||
|
(list ? `<ul>${list}</ul>` : '<div class="muted" style="margin-top:6px">No passed accounts waiting on activation.</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(); });
|
chrome.storage.onChanged.addListener((c, area) => { if (area === 'local' && 'state' in c) render(); });
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user