Files
autofirmer-expanded/extension/background.js
T
Brandon LiandClaude Opus 5.5 96fdd9ca23 Vest platform: a client for Vest Markets, traded on a session the extension relays
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy
perps on CME hours, plus stocks, FX and crypto. It joins as a fourth
platform, 'vest', on the DXtrade engine paths. Those are now keyed by
isNotionalPlatform() instead of === 'dxtrade': the firm's own symbols,
percent fees, notional sizing from volatility, and a scheduler lane of its
own.

- Login is relayed, not stored. Vest signs people in with Google through
  Privy and renews the session with a device key the browser won't export.
  Extension 0.17.0's vest-relay.js reads the token the Vest app renews for
  itself (~15 min). It renews nothing, so the tab is never logged out. The
  background script posts that token to POST /api/vest/session, which holds
  it in memory only. With no Vest tab open on a machine that trades Vest,
  the extension opens one.
- The firm's credential is the Vest login's wallet address. The dashboard's
  form offers the relayed wallet with one click.
- VestClient (lib/vest-class.ts), mapped from the web app's traffic and code
  since Vest publishes no API:
  - Reads the capital accounts, balances (free amount plus position
    collateral), positions and open orders with the user token.
  - Mints per-account tokens for orders and transactions.
  - Names accounts for their rules (V50K-E-D3-T10-34, V50K-F-D3-34,
    V25K-I-7) so account-type prefixes can match.
  - Takes Vest's own max-drawdown and daily-loss floors. dayStartBalance =
    floor + the type's limit, so the engine's room is never more than
    Vest's.
  - Uses each symbol's taker fee and margin rate from exchangeInfo, and
    hourly volatility over in-session hours only.
  - Places take-profit and stop as position legs, which Vest cancels as an
    OCO pair.
- Daily P&L and fund rows come from /v3/transactions by Vest's 20:00 ET
  day. A books check holds an account, naming the transaction types, when
  the balance and the transactions disagree.
- scripts/check-vest.mjs (in npm run check): Vest's day across DST, account
  names, parsing, volatility gaps, market hours, the session store.

Not yet run against Vest with a live session: the order and exit bodies
come from the app's code, and the transaction classification from its enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:19:31 -05:00

1725 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* AutoFirmer Capture — MV3 service worker.
*
* Loop: every tick, scrape the tab you are looking at and POST it to
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
* landed there.
*
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
* the last-focused window, and only when that tab is one of the firm sites in
* host_permissions. Sit on any other page and nothing is read or sent.
*
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one.
*
* On a firm site, the person's 2FA code for it shows in a corner chip.
*
* A signed-in Vest tab's session is relayed to the dashboard, whose Vest client
* trades with it (relayVestSession).
*/
const DEFAULTS = {
apiBase: 'http://localhost:3000',
pollSeconds: 3,
// Optional CSS selector — matching elements get their on-screen position
// measured alongside the HTML.
elementSelector: '',
};
let timer = null;
let pendingLocate = 0;
// ── Config / state ──────────────────────────────────────────────────────────
async function getConfig() {
const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
const cfg = { ...DEFAULTS, ...stored };
cfg.apiBase = String(cfg.apiBase).replace(/\/+$/, '');
return cfg;
}
/** Mirror the worker's status into storage so the popup can render it. */
async function setState(patch) {
const { state: prev } = await chrome.storage.local.get('state');
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
}
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
* working state — there is nothing to switch on, so nothing to advertise. */
function setBadge(connected) {
chrome.action.setBadgeText({ text: connected ? '' : '!' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
}
// ── The injected scraper ────────────────────────────────────────────────────
/** Runs in the page's own world. Must be self-contained — nothing from this
* file's scope is available inside it. */
function pageCapture(selector) {
// getBoundingClientRect() measures from the top-left of the viewport, so
// rect.left / rect.top ARE the element's position relative to the window.
// + scrollX/scrollY -> position within the document
// + screenX/screenY and the chrome height -> absolute desktop coordinates,
// which is what an OS-level clicker needs.
const chromeHeight = window.outerHeight - window.innerHeight;
const elements = selector
? Array.from(document.querySelectorAll(selector)).slice(0, 200).map((el, index) => {
const r = el.getBoundingClientRect();
return {
index,
tag: el.tagName.toLowerCase(),
id: el.id || null,
text: (el.textContent || '').trim().slice(0, 80),
// Off-screen or display:none elements still return a rect — all zeroes.
visible: r.width > 0 && r.height > 0,
inViewport: r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0,
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
page: { x: r.left + window.scrollX, y: r.top + window.scrollY },
// Centre of the element in desktop coordinates (CSS pixels —
// multiply by devicePixelRatio for physical pixels on HiDPI).
screen: {
x: window.screenX + r.left + r.width / 2,
y: window.screenY + chromeHeight + r.top + r.height / 2,
},
};
})
: [];
return {
url: location.href,
title: document.title,
html: document.documentElement.outerHTML,
viewport: {
scrollX: window.scrollX,
scrollY: window.scrollY,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
screenX: window.screenX,
screenY: window.screenY,
chromeHeight,
devicePixelRatio: window.devicePixelRatio,
},
elements,
};
}
// ── Capture pipeline ────────────────────────────────────────────────────────
/** The sites this extension is allowed to touch, taken from the manifest rather
* than from a setting.
*
* These are the same hosts host_permissions grants, so they cannot drift out of
* sync with what the extension can actually read, and adding a firm — which
* means adding its host to the manifest anyway — scopes capture automatically.
* localhost is filtered out: that is the dashboard, and capturing it would just
* mirror our own output back. */
function firmPatterns() {
return chrome.runtime.getManifest().host_permissions
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
}
/** The firm tab you are actually looking at, or nothing.
*
* Deliberately no fallback to "some other firm tab that happens to be open":
* capture should report the page in front of you, not a stale background tab
* you left on a broker site hours ago. Chrome does the pattern matching, so a
* tab outside host_permissions cannot be selected here at all.
*/
async function pickTab() {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
url: firmPatterns(),
});
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
}
async function captureAndSend(cfg) {
const tab = await pickTab();
if (!tab) {
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
return;
}
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageCapture,
args: [cfg.elementSelector || ''],
});
const result = injection?.result;
if (!result) throw new Error('Injection returned nothing');
const res = await fetch(`${cfg.apiBase}/api/autobuyer/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...result,
// Stamp the build so the dashboard side can tell which version of this
// worker is actually live — Chrome keeps running the old one until the
// extension is reloaded, which is invisible from the server otherwise.
viewport: { ...result.viewport, extVersion: chrome.runtime.getManifest().version },
capturedAt: Date.now(),
}),
});
if (!res.ok) throw new Error(`Capture POST failed: HTTP ${res.status}`);
await setState({
connected: true,
error: null,
waiting: null,
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
});
}
/** Which browser is hosting this extension.
*
* The clicker has to raise *this* browser before clicking, and picking by list
* order gets it wrong the moment two are installed — it would raise Edge while
* the coordinates came from a tab in Chrome, landing every click in the wrong
* window. So the answer travels with the measurement.
*/
function detectBrowser() {
const ua = navigator.userAgent || '';
if (/\bEdg\//.test(ua)) return 'edge';
if (/\bOPR\//.test(ua)) return 'opera';
if (/\bVivaldi\//.test(ua)) return 'vivaldi';
try {
if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome
} catch { /* not Brave */ }
return 'chrome';
}
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
/** Runs in the page. Scrolls the element into view, then reports where it ended up.
* `relative` is set when this runs inside a child frame: the frame's own
* window.screenX and getBoundingClientRect are relative to that frame, not the
* OS screen, so the desktop coordinate cannot be computed here — a cross-origin
* child cannot read where it sits inside its parent. In that mode it returns the
* element's position within THIS frame's viewport and leaves the composition to
* the background script, which adds the parent <iframe>'s offset (pageFrameOffset). */
function pageLocate(selector, index, relative) {
const matches = document.querySelectorAll(selector);
if (matches.length === 0) return { error: `No element matches ${selector}` };
const el = matches[index];
if (!el) return { error: `Selector ${selector} matched ${matches.length} element(s), no index ${index}` };
// Centre it in the viewport first — an element scrolled off-screen has a rect,
// but clicking those coordinates would hit whatever is actually displayed there.
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
return { error: `Element ${selector} has zero size (hidden or display:none)` };
}
if (r.bottom <= 0 || r.top >= window.innerHeight || r.right <= 0 || r.left >= window.innerWidth) {
return { error: `Element ${selector} is outside the viewport even after scrolling` };
}
const chromeHeight = window.outerHeight - window.innerHeight;
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// What sits at that point? If it's not our element (or a descendant), something
// is covering it — a cookie banner, a modal — and the click would hit that instead.
const atPoint = document.elementFromPoint(cx, cy);
const covered = atPoint && atPoint !== el && !el.contains(atPoint);
// Enough to identify it. A bare tag name — the common case for a modal
// backdrop or a captcha overlay, neither of which carries an id — says
// nothing about which overlay is in the way. SVG's className is an object,
// not a string, hence the guard.
const describeNode = (node) => {
if (!node) return null;
const classes = typeof node.className === 'string'
? node.className.trim().split(/\s+/).filter(Boolean).slice(0, 4).map((c) => `.${c}`).join('')
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}${classes}`;
};
// Current contents, so the caller can confirm afterwards that what it typed
// actually landed — a field that silently ignored the keystrokes (masked,
// read-only, or never focused) is otherwise indistinguishable from success.
const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
const value = isField ? el.value : (el.isContentEditable ? el.innerText : null);
const common = {
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim().slice(0, 80),
matchCount: matches.length,
value,
editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly,
inputType: isField ? (el.type || null) : null,
covered: !!covered,
coveredBy: covered ? describeNode(atPoint) : null,
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
url: location.href,
};
// Inside a child frame the desktop coordinate isn't ours to compute — hand
// back the element's centre within this frame's viewport and let the caller
// add the parent <iframe>'s on-screen offset.
if (relative) {
return { ...common, frameCenter: { x: cx, y: cy } };
}
return {
...common,
// Desktop coordinates of the element's centre, in CSS pixels.
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
// Lets the Python side work out whether the OS uses a different pixel
// scale than CSS does (Windows display scaling, some Linux setups).
screenSize: { width: window.screen.width, height: window.screen.height },
devicePixelRatio: window.devicePixelRatio,
chromeHeight,
};
}
/** Runs in the TOP frame. Measures where a child <iframe> sits and reports the
* top window's screen origin, so the background script can turn a frame-relative
* element position (pageLocate's `relative` mode) into desktop coordinates. This
* cannot be done from inside the child: a cross-origin frame can read neither its
* own offset within the parent nor the top window's screenX/screenY. */
function pageFrameOffset(frameSelector) {
const frame = document.querySelector(frameSelector);
if (!frame) return { error: `No element matches ${frameSelector}` };
if (frame.tagName !== 'IFRAME') {
return { error: `${frameSelector} is a <${frame.tagName.toLowerCase()}>, not an <iframe>` };
}
frame.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const r = frame.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
return { error: `Frame ${frameSelector} has zero size (hidden or display:none)` };
}
return {
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
src: frame.getAttribute('src') || frame.src || '',
name: frame.getAttribute('name') || '',
// The TOP tab's URL. The signed-out guard keys off "what page is the tab
// on", which is a top-frame fact — the child frame's own URL (e.g. a
// checkout iframe on the apex host) is not it, and reporting that instead
// makes the guard fire while signed in.
topUrl: location.href,
screenX: window.screenX,
screenY: window.screenY,
chromeHeight: window.outerHeight - window.innerHeight,
screenSize: { width: window.screen.width, height: window.screen.height },
devicePixelRatio: window.devicePixelRatio,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
};
}
/** Runs in the page. Answers "is this element there, and can it be seen?" —
* no scrolling, no measuring, no error when it is missing. pageLocate cannot
* answer this: absent and hidden both come back as errors there, and a wait
* gate has to tell those two apart from a broken pipeline. */
function pageVisible(selector, index) {
const matches = document.querySelectorAll(selector);
const el = matches[index];
if (!el) {
return {
op: 'visible', present: false, visible: false, matchCount: matches.length,
reason: matches.length ? `matched ${matches.length} element(s), no index ${index}` : 'no element matches',
};
}
// checkVisibility covers what CSS can do to make an element invisible while
// leaving it in the DOM — display, visibility, content-visibility, opacity —
// including when the property sits on an ancestor. Both spellings of the
// options are passed: Chrome renamed them, and unknown keys are ignored.
const rendered = typeof el.checkVisibility === 'function'
? el.checkVisibility({
checkVisibilityCSS: true, checkOpacity: true,
visibilityProperty: true, opacityProperty: true,
contentVisibilityAuto: true,
})
: true;
// Size is not part of that check, and a container collapsed to nothing is
// just as invisible as one that was hidden outright.
const r = el.getBoundingClientRect();
const sized = r.width > 0 && r.height > 0;
return {
op: 'visible',
present: true,
visible: rendered && sized,
matchCount: matches.length,
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim().slice(0, 80),
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
reason: rendered ? (sized ? null : 'zero size') : 'hidden by CSS',
url: location.href,
};
}
/** Is the tab already showing this page? Compares origin and path only —
* query strings and hashes shouldn't force a reload. */
function alreadyAt(current, target) {
try {
const a = new URL(current);
const b = new URL(target);
return a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '');
} catch {
return false;
}
}
/** Resolve after the tab reports `complete`. A tab that has only just been
* created has no DOM to inject into yet. */
function waitForTabLoad(tabId, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const started = Date.now();
const check = async () => {
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch {
return reject(new Error('the tab was closed while loading'));
}
if (tab.status === 'complete') return resolve(tab);
if (Date.now() - started > timeoutMs) {
return reject(new Error(`the page did not finish loading within ${timeoutMs / 1000}s`));
}
setTimeout(check, 200);
};
check();
});
}
/** Runs in the page. Scrolls until the list stops growing, then reports what it
* ended up with.
*
* The scrolling element is often NOT the window — lists like this usually live
* in a div with its own overflow, and scrolling the document does nothing at
* all. So walk up from a matched item looking for the ancestor that actually
* scrolls, and let the caller name one outright when the guess is wrong.
*/
async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const count = () => document.querySelectorAll(selector).length;
function scroller() {
if (containerSelector) {
const named = document.querySelector(containerSelector);
if (!named) return { error: `No element matches container ${containerSelector}` };
return { el: named };
}
// An ancestor that can actually scroll: overflow allows it, and there is
// more content than fits.
let node = document.querySelector(selector)?.parentElement ?? null;
while (node && node !== document.body) {
const overflow = getComputedStyle(node).overflowY;
if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) {
return { el: node };
}
node = node.parentElement;
}
return { el: document.scrollingElement || document.documentElement, isDocument: true };
}
const found = scroller();
if (found.error) return { error: found.error };
const el = found.el;
const before = count();
let last = before;
let stable = 0;
let scrolls = 0;
while (scrolls < maxScrolls) {
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
el.scrollTop = el.scrollHeight;
scrolls++;
await sleep(settleMs);
const now = count();
// Nothing new AND we were already pinned to the bottom: the list is done
// growing, not merely slow.
stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0);
last = now;
if (stable >= stableRounds) break;
}
return {
before,
after: last,
scrolls,
exhausted: stable >= stableRounds,
container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60),
url: location.href,
};
}
// ── 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
* 2captcha to solve it, or reports that there isn't one yet.
*
* Three sources, best first: the render hook (the only one that sees
* programmatically-passed parameters), the widget's own data- attributes, and
* finally the challenge iframe, whose URL carries the sitekey when nothing else
* does. */
function pageCaptchaInfo(scopeSelector, wanted) {
const root = scopeSelector ? document.querySelector(scopeSelector) : document;
if (!root) return { error: `No element matches ${scopeSelector}` };
const KINDS = {
turnstile: { cls: '.cf-turnstile', frame: 'challenges.cloudflare.com' },
recaptcha: { cls: '.g-recaptcha', frame: '/recaptcha/' },
hcaptcha: { cls: '.h-captcha', frame: 'hcaptcha.com' },
};
const want = (kind) => !wanted || wanted === 'auto' || wanted === kind;
const enterprise = !!document.querySelector('script[src*="recaptcha/enterprise"]');
// Cloudflare's iframe has no sitekey parameter — it is a path segment, and
// every Turnstile key starts with 0x.
const fromFrameUrl = (kind, src) => {
try {
const url = new URL(src, location.href);
if (kind === 'recaptcha') return url.searchParams.get('k');
if (kind === 'hcaptcha') return url.searchParams.get('sitekey');
return url.pathname.split('/').find((part) => /^0x[A-Za-z0-9_-]{8,}$/.test(part)) || null;
} catch {
return null;
}
};
// Newest first. A page that renders a fresh widget per modal leaves the
// earlier ones in the registry — they are never unregistered — and the one
// that matters is the one just rendered, not the first ever seen.
for (const widget of [...(window.__autofirmerCaptcha?.widgets || [])].reverse()) {
const params = widget.params || {};
if (!want(widget.kind) || !params.sitekey) continue;
return {
provider: widget.kind,
sitekey: params.sitekey,
pageurl: location.href,
action: params.action || '',
data: params.cData || '',
pagedata: params.chlPageData || '',
invisible: params.size === 'invisible',
enterprise,
userAgent: navigator.userAgent,
source: 'render hook',
};
}
for (const kind of Object.keys(KINDS)) {
if (!want(kind)) continue;
const el = root.querySelector(`${KINDS[kind].cls}[data-sitekey]`);
if (!el) continue;
return {
provider: kind,
sitekey: el.getAttribute('data-sitekey'),
pageurl: location.href,
action: el.getAttribute('data-action') || '',
data: el.getAttribute('data-cdata') || '',
pagedata: '',
invisible: el.getAttribute('data-size') === 'invisible',
enterprise,
userAgent: navigator.userAgent,
source: 'data-sitekey',
};
}
const frames = Array.from(root.querySelectorAll('iframe'));
for (const kind of Object.keys(KINDS)) {
if (!want(kind)) continue;
for (const frame of frames) {
const src = frame.getAttribute('src') || '';
if (!src.includes(KINDS[kind].frame)) continue;
const sitekey = fromFrameUrl(kind, src);
if (!sitekey) continue;
return {
provider: kind,
sitekey,
pageurl: location.href,
action: '',
data: '',
pagedata: '',
invisible: false,
enterprise,
userAgent: navigator.userAgent,
source: 'challenge iframe',
};
}
}
return { error: 'No captcha widget on the page yet' };
}
/** Runs in the page's MAIN world. Puts a solved token where the page looks for
* it, then tells the page it arrived.
*
* Both halves matter. The hidden response field is what the form posts, but
* most pages enable their submit button from the widget's callback and never
* read the field until then — so filling it alone leaves the page looking
* unsolved. Only the FIRST callback found is fired: they are the page's own
* submit handlers, and calling several would risk sending the form twice. */
function pageCaptchaSubmit(token, provider) {
const FIELDS = {
turnstile: ['cf-turnstile-response', 'g-recaptcha-response'],
recaptcha: ['g-recaptcha-response'],
hcaptcha: ['h-captcha-response', 'g-recaptcha-response'],
};
const targets = new Set();
for (const name of (FIELDS[provider] || FIELDS.recaptcha)) {
document.querySelectorAll(`[name="${name}"], #${name}`).forEach((el) => targets.add(el));
}
// Turnstile names its hidden input per widget: cf-chl-widget-<id>_response.
document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el));
let filled = 0;
const fieldIds = [];
for (const el of targets) {
el.value = token;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
fieldIds.push(el.id || `(${el.getAttribute('name')})`);
filled++;
}
// reCAPTCHA numbers its response fields by widget id: the first widget gets
// #g-recaptcha-response, the second #g-recaptcha-response-1, and so on. That
// suffix is the only reliable link from the field the open modal is actually
// showing back to the client object holding ITS callback.
//
// Guessing wrong is invisible from the outside and looks exactly like the
// site ignoring a good token: the callback runs, but on a widget belonging
// to a modal that has already closed, so nothing stores the token and the
// confirm button never enables.
let liveWidgetId = null;
for (const el of targets) {
const match = /^g-recaptcha-response(?:-(\d+))?$/.exec(el.id || '');
if (match) { liveWidgetId = Number(match[1] || 0); break; }
}
let called = null;
const invoke = (fn, where) => {
if (called || typeof fn !== 'function') return false;
try {
fn(token);
called = where;
return true;
} catch (err) {
// The page's own handler threw. That is its business, but the token
// did reach it, so don't go looking for another one to call.
called = `${where} (threw: ${err.message})`;
return true;
}
};
// Newest first, for the same reason detection is: firing the callback of a
// widget belonging to a modal that has already closed hands the token to
// nobody, and the live widget never hears that it was solved — which looks
// from the outside like the site simply ignoring a correct token.
const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse();
let candidates = 0;
let clientKeys = [];
for (const widget of hookWidgets) {
if (provider && provider !== 'auto' && widget.kind !== provider) continue;
candidates++;
if (called) continue;
const cb = (widget.params || {}).callback;
if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`);
else if (typeof cb === 'string') invoke(window[cb], cb);
}
if (!called) {
for (const el of document.querySelectorAll('[data-callback]')) {
const name = el.getAttribute('data-callback');
if (invoke(window[name], `data-callback ${name}`)) break;
}
}
// reCAPTCHA rendered declaratively keeps its callback in a private config
// object and nowhere else. The keys around it are minified; `callback` is not.
if (window.___grecaptcha_cfg?.clients) {
// Clients accumulate: reCAPTCHA adds one per render and removes none, so
// after the second modal there are two and only one is live. Try the one
// the response field named, then the rest newest-first as a fallback.
const all = Object.entries(window.___grecaptcha_cfg.clients);
clientKeys = all.map(([key]) => key);
candidates += all.length;
const ordered = all
.sort(([a], [b]) => Number(b) - Number(a))
.sort(([a], [b]) => (Number(a) === liveWidgetId ? -1 : Number(b) === liveWidgetId ? 1 : 0));
for (const [key, client] of ordered) {
if (called) break;
// Depth-limited walk: the keys around it are minified, `callback`
// is not.
const seen = new Set();
const queue = [client];
let budget = 2000;
while (queue.length && !called && budget-- > 0) {
const node = queue.shift();
if (!node || typeof node !== 'object' || seen.has(node)) continue;
seen.add(node);
for (const [prop, value] of Object.entries(node)) {
if (typeof value === 'function') {
if (/callback/i.test(prop) && invoke(value, `grecaptcha ${prop} (widget ${key})`)) break;
} else if (value && typeof value === 'object') {
queue.push(value);
}
}
}
}
}
if (!filled && !called) {
return { error: 'nowhere to put the token — no response field and no callback on the page' };
}
// `candidates` is the diagnostic that matters when a token is accepted and
// the page does nothing: more than one means the page has stale widgets, and
// the token went to whichever this picked.
return {
filled,
called,
candidates,
// The diagnostic that matters when a token is accepted and the page does
// nothing: which field carried it, and which widgets were on offer.
fieldIds,
liveWidgetId,
clientKeys,
provider,
url: location.href,
};
}
async function resolveLocateTab(cfg, request) {
// Every step carries its firm's pattern; the manifest hosts are the fallback
// for a bare request (the CLI's locate without --url).
const pattern = request.urlPattern || firmPatterns();
if (pattern) {
const tabs = await chrome.tabs.query({ url: pattern });
const tab = tabs.find((t) => /^https?:/.test(t.url || ''));
if (tab) return { tab, opened: false };
// Nothing matching is open. Open it rather than failing the run — but only
// to the URL the firm declared, never to something a request supplied that
// we have no host permission for.
if (request.openUrl) {
const created = await chrome.tabs.create({ url: request.openUrl, active: true });
await waitForTabLoad(created.id);
return { tab: await chrome.tabs.get(created.id), opened: true };
}
throw new Error(`No open tab matches ${pattern}, and no URL is configured to open`);
}
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (!tab || !/^https?:/.test(tab.url || '')) throw new Error('No eligible active tab');
return { tab, opened: false };
}
/** Hand an answer — or a failure — back to whoever queued the request. */
async function postLocateResult(cfg, id, result, error) {
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, result, error }),
});
}
/** Returns true if a request was handled, false if the queue was empty. */
async function serveLocateRequest(cfg) {
const claim = await fetch(`${cfg.apiBase}/api/autobuyer/locate/claim`, { method: 'POST' });
if (!claim.ok) throw new Error(`Claim failed: HTTP ${claim.status}`);
const { request } = await claim.json();
if (!request) return false;
let result = null;
let error = null;
try {
let { tab, opened } = await resolveLocateTab(cfg, request);
await hideOtpChip(tab.id);
// A navigate step points the tab at a specific page first. Skip it when we
// are already there — reloading would throw away page state for nothing,
// and the common case is that the tab is on the right page already.
if (request.navigateUrl && !alreadyAt(tab.url, request.navigateUrl)) {
await chrome.tabs.update(tab.id, { url: request.navigateUrl });
await waitForTabLoad(tab.id);
tab = await chrome.tabs.get(tab.id);
opened = true; // treat as a cold load: the app still has to mount
}
// The reported coordinates are only worth anything if that tab is the one
// actually visible: raise its window and bring the tab to the front.
// Only un-minimise — passing state:'normal' unconditionally would drop a
// maximised or fullscreen window out of that state on every single click.
const win = await chrome.windows.get(tab.windowId);
const focus = win.state === 'minimized'
? { focused: true, state: 'normal' }
: { focused: true };
await chrome.windows.update(tab.windowId, focus);
await chrome.tabs.update(tab.id, { active: true });
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
// A scroll request is a different operation on the same channel: no
// element is measured, the page is just walked to the bottom.
if (request.options && request.options.op === 'scrollToLoad') {
const [scrolled] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageScrollToLoad,
args: [
request.selector,
request.options.containerSelector || '',
Number(request.options.maxScrolls) || 25,
Number(request.options.settleMs) || 800,
Number(request.options.stableRounds) || 2,
],
});
const out = scrolled?.result;
if (!out) throw new Error('Scroll injection returned nothing');
if (out.error) throw new Error(out.error);
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, null);
return true;
}
// Captcha ops are the same shape: no element is measured, the page is
// read for its widget parameters or handed a token 2captcha solved.
// Both run in the page's MAIN world — the isolated world cannot see the
// page's own `turnstile` / `grecaptcha` objects, nor the render hook's
// registry, and those hold the callbacks a token has to be given to.
// allFrames because the widget is often in a same-origin sub-frame;
// Chrome skips frames outside host_permissions, so the challenge iframe
// itself is never touched.
if (request.options && (request.options.op === 'captchaInfo' || request.options.op === 'captchaToken')) {
const isInfo = request.options.op === 'captchaInfo';
const injections = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
world: 'MAIN',
func: isInfo ? pageCaptchaInfo : pageCaptchaSubmit,
args: isInfo
? [request.options.scopeSelector || '', request.options.provider || 'auto']
: [String(request.options.token || ''), request.options.provider || 'auto'],
});
const answers = injections.map((i) => i?.result).filter(Boolean);
const found = answers.find((a) => !a.error);
if (!found) {
throw new Error(answers[0]?.error || 'Captcha injection returned nothing');
}
result = { ...found, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, null);
return true;
}
// A step can target an element inside a cross-origin <iframe> — the
// WooCommerce checkout modal is one. A selector run against the top
// document never matches it, so resolve it in two parts: measure where the
// <iframe> sits (top frame, pageFrameOffset) and find the element inside
// whichever child frame holds it (all frames, pageLocate in `relative`
// mode), then add the two together to reach desktop coordinates.
if (request.options && request.options.frame) {
const frameSel = request.options.frame;
// A frame-scoped visibility probe: no coordinates needed, just report
// whether the element is there and rendered in some child frame.
if (request.options.op === 'visible') {
const injections = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
func: pageVisible,
args: [request.selector, request.index || 0],
});
const hits = injections.filter((i) => i.frameId !== 0 && i.result);
const present = hits.find((i) => i.result.present);
const chosen = present ? present.result
: (hits[0]?.result
|| { op: 'visible', present: false, visible: false, matchCount: 0, reason: 'no subframe matched' });
result = { ...chosen, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, null);
return true;
}
// Where the iframe sits, and the top window's screen origin.
const [offsetInj] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageFrameOffset,
args: [frameSel],
});
const offset = offsetInj?.result;
if (!offset) throw new Error('Frame-offset injection returned nothing');
if (offset.error) throw new Error(offset.error);
// The element, measured inside whatever child frame holds it. Retry
// while the checkout iframe is still mounting — same budget as the
// top-frame path below.
const frameDeadline = Date.now() + (opened ? 8000 : 2500);
let hit;
for (;;) {
const injections = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
func: pageLocate,
args: [request.selector, request.index || 0, true],
});
// Only child frames; the top frame is handled by the normal path.
const hits = injections.filter((i) => i.frameId !== 0 && i.result && !i.result.error);
if (hits.length === 1) { hit = hits[0].result; break; }
if (hits.length > 1) {
throw new Error(
`Selector ${request.selector} matched in ${hits.length} subframes `
+ `(${hits.map((h) => h.result.url).join(', ')}) — narrow it`);
}
// Phrased with "No element matches" so the runner treats it as
// absent (and skipIfNotFound can skip it), same as the top frame.
if (Date.now() >= frameDeadline) {
throw new Error(`No element matches ${request.selector} in frame ${frameSel}`);
}
await new Promise((r) => setTimeout(r, 350));
}
// Compose: the element's centre in the top viewport is where the
// iframe is plus where the element is inside it. From there the screen
// origin and browser-chrome height (both the top window's) give desktop
// pixels, exactly as pageLocate does for a top-level element.
const tx = offset.rect.left + hit.frameCenter.x;
const ty = offset.rect.top + hit.frameCenter.y;
if (tx < 0 || ty < 0 || tx >= offset.innerWidth || ty >= offset.innerHeight) {
throw new Error(
`Element ${request.selector} resolves outside the viewport `
+ `(frame ${frameSel} is partly off-screen)`);
}
result = {
tag: hit.tag,
text: hit.text,
matchCount: hit.matchCount,
value: hit.value,
editable: hit.editable,
inputType: hit.inputType,
covered: hit.covered,
coveredBy: hit.coveredBy,
// The tab's page, for the signed-out guard — not the child frame's
// own URL, which is kept as frameUrl for debugging.
url: offset.topUrl,
frameUrl: hit.url,
viewport: {
x: offset.rect.left + hit.viewport.x,
y: offset.rect.top + hit.viewport.y,
width: hit.viewport.width,
height: hit.viewport.height,
},
screen: { x: offset.screenX + tx, y: offset.screenY + offset.chromeHeight + ty },
screenSize: offset.screenSize,
devicePixelRatio: offset.devicePixelRatio,
chromeHeight: offset.chromeHeight,
tabId: tab.id,
windowId: tab.windowId,
openedTab: opened,
browser: detectBrowser(),
};
await postLocateResult(cfg, request.id, result, null);
return true;
}
// A visibility probe is a read, not a lookup: it reports on the element
// instead of failing when there isn't one, so it never retries and never
// errors on a missing selector — being missing is the answer.
if (request.options && request.options.op === 'visible') {
const [probe] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageVisible,
args: [request.selector, request.index || 0],
});
const out = probe?.result;
if (!out) throw new Error('Visibility injection returned nothing');
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, null);
return true;
}
// `complete` only means the document loaded — a React app still has to
// mount and paint. Retry briefly rather than declaring the element missing,
// with a longer budget when we just opened the page from cold.
const deadline = Date.now() + (opened ? 8000 : 2500);
let out;
for (;;) {
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageLocate,
args: [request.selector, request.index || 0],
});
out = injection?.result;
if (!out) throw new Error('Injection returned nothing');
if (!out.error) break;
if (Date.now() >= deadline) throw new Error(out.error);
await new Promise((r) => setTimeout(r, 350));
}
result = {
...out,
tabId: tab.id,
windowId: tab.windowId,
openedTab: opened,
browser: detectBrowser(),
};
} catch (err) {
error = err.message;
}
await postLocateResult(cfg, request.id, result, error);
return true;
}
async function tick() {
const cfg = await getConfig();
let status;
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
status = await res.json();
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false);
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
return;
}
setBadge(true);
await setState({ connected: true });
// Only when nothing is queued: a reload mid-run drops the locate the runner
// is blocked on. There is no hurry — the next tick is seconds away.
if (!pendingLocate) {
try {
await maybeSelfUpdate(status.extensionVersion);
} catch (err) {
await setState({ connected: true, error: `Self-update check: ${err.message}` });
}
}
// Serve queued clicks before capturing — the Python side is waiting on these,
// and a capture of a megabyte page shouldn't sit in front of them.
// Failures inside a request are reported back to the caller; a failure of the
// queue plumbing itself lands here, and must not take the capture loop down.
try {
for (let i = 0; i < pendingLocate; i++) {
if (!(await serveLocateRequest(cfg))) break;
}
} catch (err) {
await setState({ connected: true, error: `Locate queue: ${err.message}` });
}
try {
await captureAndSend(cfg);
} catch (err) {
await setState({ connected: true, error: err.message });
}
try {
await maybeShowOtp(cfg);
} catch (err) {
await setState({ connected: true, error: `2FA chip: ${err.message}` });
}
try {
await relayVestSession(cfg);
} catch (err) {
await setState({ connected: true, vest: `Vest relay: ${err.message}` });
}
}
// ── Vest: relay the signed-in tab's session to the dashboard ────────────────
//
// Vest signs people in through Google in the browser and renews the session
// with a device key the browser won't hand out, so the dashboard cannot log in
// by itself. vest-relay.js, in the Vest tab, keeps the latest access token the
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
// the dashboard whenever it changes. Nothing here renews or signs anything: the
// session stays the app's.
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
const VEST_EVERY_MS = 10_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 vestOpenedAt = 0;
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
* it has life left. Self-contained. */
function pageVestSession() {
const s = window.__afVestSession;
return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null };
}
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
async function fetchVestWanted(cfg) {
try {
const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' });
if (!res.ok) return [];
const body = await res.json();
return Array.isArray(body.wanted) ? body.wanted : [];
} catch {
return [];
}
}
async function relayVestSession(cfg) {
const now = Date.now();
if (now - vestLastCheck < VEST_EVERY_MS) return;
vestLastCheck = now;
const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || ''));
if (tabs.length === 0) {
// This machine trades Vest: open the app in the background. It signs
// itself back in from its 30-day session, and the hook catches that.
const wanted = await fetchVestWanted(cfg);
if (wanted.length === 0) return;
if (now - vestOpenedAt > VEST_REOPEN_MS) {
vestOpenedAt = now;
await chrome.tabs.create({ url: VEST_URL, active: false });
await setState({ vest: 'Opened a Vest tab to relay its session' });
} else {
await setState({ vest: 'No Vest tab open' });
}
return;
}
let found = null;
for (const tab of tabs) {
// A discarded tab stops renewing the session; keep Chrome from it.
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession,
}).catch(() => [null]);
const out = injection?.result;
if (out && !out.hooked) {
// A tab open from before this build: hook it now, and its app's
// next renewal is caught.
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
}
if (out?.session) { found = out.session; break; }
}
if (!found) {
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
return;
}
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
if (fingerprint === vestLastRelayed) return;
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId,
}),
});
const body = await res.json().catch(() => ({}));
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;
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` });
}
// ── 2FA: the person's authenticator code for the firm site you are on ───────
//
// The master keeps the authenticator secrets, each assigned to a firm (whose
// site link it matches against) and a person. The dashboard knows which
// person this machine is, so the extension only sends the tab's host and gets
// back that person's live codes for the site — shown in a small chip in the
// corner with a Copy button.
/** Refetch at least this often, so a newly assigned code turns up. */
const OTP_REFRESH_MS = 30_000;
/** No chip for this long after the runner last located something: its clicks
* land on screen coordinates, and a chip there would take them. */
const OTP_QUIET_AFTER_LOCATE_MS = 60_000;
let otpCache = null; // { host, codes, fetchedAt }
let lastLocateAt = 0;
const chipTabs = new Set(); // tabs we have drawn a chip in
/** This person's codes for a host, with expiresAt moved onto this clock. An
* unreachable dashboard returns nothing and retries next tick; any answer —
* including an old dashboard's 404 — is cached until a code expires. */
async function fetchOtpCodes(cfg, host) {
const now = Date.now();
if (otpCache && otpCache.host === host && now - otpCache.fetchedAt < OTP_REFRESH_MS
&& otpCache.codes.every((c) => c.expiresAt > now)) {
return otpCache.codes;
}
let codes = [];
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/otp?host=${encodeURIComponent(host)}`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
// The master's clock made the codes; a countdown on ours needs the gap.
const drift = (Number(data.serverTime) || now) - now;
codes = (Array.isArray(data.codes) ? data.codes : [])
.filter((c) => c && typeof c.code === 'string')
.map((c) => ({
label: String(c.label || ''),
firmName: c.firmName ? String(c.firmName) : '',
code: c.code,
period: Number(c.period) || 30,
expiresAt: Number(c.expiresAt) - drift,
}));
}
} catch {
return otpCache && otpCache.host === host ? otpCache.codes.filter((c) => c.expiresAt > now) : [];
}
otpCache = { host, codes, fetchedAt: now };
return codes;
}
async function maybeShowOtp(cfg) {
const tab = await pickTab();
if (!tab) return;
if (Date.now() - lastLocateAt < OTP_QUIET_AFTER_LOCATE_MS) return;
let host;
try { host = new URL(tab.url).hostname; } catch { return; }
const codes = await fetchOtpCodes(cfg, host);
// An empty list is still drawn once where a chip was, which removes it.
if (!codes.length && !chipTabs.has(tab.id)) return;
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageOtpChip, args: [codes] });
if (codes.length) chipTabs.add(tab.id); else chipTabs.delete(tab.id);
}
/** Take the chip down before the runner measures or clicks anything there. */
async function hideOtpChip(tabId) {
lastLocateAt = Date.now();
if (!chipTabs.has(tabId)) return;
chipTabs.delete(tabId);
try {
await chrome.scripting.executeScript({ target: { tabId }, func: pageOtpChip, args: [[]] });
} catch {
// tab gone or navigated away — nothing left to hide
}
}
chrome.tabs.onRemoved.addListener((tabId) => chipTabs.delete(tabId));
/** Draws, updates or (with no codes) removes the 2FA chip. Runs in the
* extension's isolated world and must be self-contained. The chip lives in a
* shadow root so the site's CSS can't reach it, and counts down on its own
* between the worker's updates. */
function pageOtpChip(codes) {
const ID = 'autofirmer-otp';
const COLLAPSED_KEY = 'autofirmer-otp-collapsed';
let host = document.getElementById(ID);
if (!codes || !codes.length) {
if (host) host.remove();
return;
}
// A chip drawn by an older build has no updater; start over.
if (host && typeof host.__afOtp !== 'function') {
host.remove();
host = null;
}
if (host) {
host.__afOtp(codes);
return;
}
host = document.createElement('div');
host.id = ID;
host.style.cssText = 'all: initial; position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { all: initial; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.chip { width: 260px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, .18); overflow: hidden; }
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 6px 12px;
font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #64748b; }
.icon { border: 0; background: none; color: #94a3b8; font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
.icon:hover { color: #334155; background: #f1f5f9; }
.row { padding: 6px 12px 10px; border-top: 1px solid #f1f5f9; }
.row:first-child { border-top: 0; }
.label { font-size: 12px; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.code { font-size: 24px; font-weight: 700; letter-spacing: .12em; font-variant-numeric: tabular-nums; }
.secs { font-size: 11px; font-weight: 600; color: #94a3b8; font-variant-numeric: tabular-nums; margin-left: auto; }
.soon { color: #dc2626; }
.copy { border: 1px solid #e2e8f0; background: #fff; color: #475569; font-size: 12px; font-weight: 600;
padding: 5px 10px; border-radius: 8px; cursor: pointer; }
.copy:hover { background: #f1f5f9; }
.copy.done { border-color: #86efac; color: #15803d; background: #f0fdf4; }
.bar { height: 3px; background: #f1f5f9; border-radius: 2px; margin-top: 6px; overflow: hidden; }
.fill { height: 100%; background: #22c55e; }
.fill.soon { background: #ef4444; }
.pill { border: 1px solid #e2e8f0; background: #fff; color: #0f172a; font-size: 12px; font-weight: 700;
letter-spacing: .06em; padding: 7px 12px; border-radius: 999px; cursor: pointer;
box-shadow: 0 6px 18px rgba(15, 23, 42, .16); }
.pill:hover { background: #f8fafc; }
</style>
<div class="chip">
<div class="head"><span>2FA code</span><button class="icon" title="Minimise">–</button></div>
<div class="rows"></div>
</div>
<button class="pill" title="Show the 2FA code">2FA</button>`;
const chip = root.querySelector('.chip');
const pill = root.querySelector('.pill');
const rowsEl = root.querySelector('.rows');
let rows = [];
let signature = '';
const collapsed = () => { try { return sessionStorage.getItem(COLLAPSED_KEY) === '1'; } catch { return false; } };
const setCollapsed = (on) => {
try { sessionStorage.setItem(COLLAPSED_KEY, on ? '1' : '0'); } catch { /* storage blocked */ }
chip.style.display = on ? 'none' : '';
pill.style.display = on ? '' : 'none';
};
root.querySelector('.icon').addEventListener('click', () => setCollapsed(true));
pill.addEventListener('click', () => setCollapsed(false));
setCollapsed(collapsed());
function flash(button, text, done) {
button.textContent = text;
button.classList.toggle('done', done);
setTimeout(() => { button.textContent = 'Copy'; button.classList.remove('done'); }, 1500);
}
// execCommand first: it is synchronous inside the click, needs no
// clipboard permission, and never stalls on a prompt the way the async
// Clipboard API can. Focus goes back to whatever had it (the code field).
function copy(text, button) {
const had = document.activeElement;
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position: fixed; top: 0; left: 0; opacity: 0;';
root.appendChild(area);
area.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch { /* fall through */ }
area.remove();
if (had && had !== document.body && typeof had.focus === 'function') had.focus();
if (ok) return flash(button, 'Copied', true);
if (!navigator.clipboard) return flash(button, 'Copy failed', false);
navigator.clipboard.writeText(text).then(
() => flash(button, 'Copied', true),
() => flash(button, 'Copy failed', false),
);
}
function build(list) {
rowsEl.textContent = '';
rows = list.map((c) => {
const row = document.createElement('div');
row.className = 'row';
const label = document.createElement('div');
label.className = 'label';
label.textContent = [c.firmName, c.label].filter(Boolean).join(' · ');
label.title = label.textContent;
const line = document.createElement('div');
line.className = 'line';
const code = document.createElement('span');
code.className = 'code';
const secs = document.createElement('span');
secs.className = 'secs';
const button = document.createElement('button');
button.className = 'copy';
button.textContent = 'Copy';
// Keep focus where it is (the site's code field), so ⌘V follows.
button.addEventListener('mousedown', (e) => e.preventDefault());
button.addEventListener('click', () => copy(c.code, button));
line.append(code, secs, button);
const bar = document.createElement('div');
bar.className = 'bar';
const fill = document.createElement('div');
fill.className = 'fill';
bar.appendChild(fill);
row.append(label, line, bar);
rowsEl.appendChild(row);
return { c, code, secs, fill, button };
});
}
function paint() {
const now = Date.now();
for (const r of rows) {
const msLeft = r.c.expiresAt - now;
const left = Math.max(0, Math.ceil(msLeft / 1000));
const soon = left <= 5;
// Expired: blank it until the worker brings the next one (seconds).
const half = Math.ceil(r.c.code.length / 2);
r.code.textContent = left > 0 ? `${r.c.code.slice(0, half)} ${r.c.code.slice(half)}` : '… …';
r.code.classList.toggle('soon', soon && left > 0);
r.secs.textContent = left > 0 ? `${left}s` : '';
r.secs.classList.toggle('soon', soon);
r.fill.style.width = `${Math.max(0, Math.min(1, msLeft / (r.c.period * 1000))) * 100}%`;
r.fill.classList.toggle('soon', soon);
r.button.disabled = left === 0;
}
}
host.__afOtp = (list) => {
const next = list.map((c) => `${c.firmName}|${c.label}|${c.code}`).join('\n');
if (next !== signature) {
signature = next;
build(list);
} else {
rows.forEach((r, i) => { r.c = list[i]; });
}
paint();
};
const timer = setInterval(() => {
if (!host.isConnected) { clearInterval(timer); return; }
paint();
}, 250);
(document.body || document.documentElement).appendChild(host);
host.__afOtp(codes);
}
// ── Keeping ourselves current ───────────────────────────────────────────────
/** -1 / 0 / 1 over dotted numeric versions; missing parts count as zero. */
function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff) return diff < 0 ? -1 : 1;
}
return 0;
}
/** How we were installed: 'admin' when Chrome policy pulled us from the update
* server, 'development' when somebody loaded the folder unpacked. getSelf is
* the one chrome.management call that needs no permission. */
async function installType() {
try {
const self = await chrome.management.getSelf();
return self.installType || 'development';
} catch {
return 'development';
}
}
/** chrome.runtime.requestUpdateCheck as a promise; null if the call is not
* available at all. */
function requestUpdateCheck() {
return new Promise((resolve) => {
try {
chrome.runtime.requestUpdateCheck((result) => {
if (chrome.runtime.lastError) return resolve(null);
resolve(result?.status || 'no_update');
});
} catch {
resolve(null);
}
});
}
/** Get onto the newer build the dashboard is advertising.
*
* Two different mechanisms, because there are two ways this extension gets
* installed. A policy install has a real update channel: Chrome polls the CRX
* on the droplet on its own schedule, and all we add is "check now" so a
* release lands in seconds rather than within five hours.
*
* An unpacked load has no channel at all — Chrome reads the folder once and
* never looks again — but chrome.runtime.reload() re-reads it from disk, which
* the update check has already pulled into. That can only ever run code that is
* already there, so a dashboard ahead of this machine's pull would have us
* reload into the same version forever. Hence one attempt per version pair:
* come back unchanged and it says so and waits. A dashboard that is *behind* is
* left alone either way — downgrading is not something we can do. */
async function maybeSelfUpdate(expected) {
const running = chrome.runtime.getManifest().version;
const { updateAttempt } = await chrome.storage.local.get('updateAttempt');
if (!expected || compareVersions(expected, running) <= 0) {
// Nothing to do — and if we got here off the back of a reload, that
// reload worked, so clear the note it left in the popup.
if (updateAttempt) {
await chrome.storage.local.remove('updateAttempt');
await setState({ error: '' });
}
return;
}
if ((await installType()) !== 'development') {
const status = await requestUpdateCheck();
// Chrome swaps the extension out from under us when the CRX arrives, so
// there is nothing to do afterwards but say what was asked for.
await setState({
error: status === 'throttled'
? `Running v${running}, v${expected} is published — Chrome is rate-limiting `
+ `update checks and will pick it up shortly.`
: `Running v${running}, asked Chrome to fetch v${expected} (${status || 'no answer'}).`,
});
return;
}
if (updateAttempt && updateAttempt.expected === expected && updateAttempt.running === running) {
await setState({
error: `Running v${running}, the dashboard ships v${expected}. Reloaded once and `
+ `the files on disk were still the old ones — waiting for the next pull. `
+ `If this sticks, pull and reload the card in chrome://extensions.`,
});
return;
}
// Recorded *before* the reload: the worker does not come back from this call,
// so anything written after it would never be written at all.
await chrome.storage.local.set({ updateAttempt: { expected, running, at: Date.now() } });
await setState({ error: `Reloading to pick up v${expected} (running v${running})` });
chrome.runtime.reload();
}
// ── Scheduling ──────────────────────────────────────────────────────────────
async function restart() {
if (timer) clearInterval(timer);
const cfg = await getConfig();
// Re-check after the await: restart() is called from several places at startup,
// and without this a concurrent call leaks an untracked interval — two polling
// loops in one worker, capturing everything twice.
if (timer) clearInterval(timer);
const ms = Math.max(1, Number(cfg.pollSeconds) || DEFAULTS.pollSeconds) * 1000;
timer = setInterval(tick, ms);
tick();
}
chrome.runtime.onInstalled.addListener(restart);
chrome.runtime.onStartup.addListener(restart);
// The setInterval above only survives while the worker is alive. Each tick makes
// an extension API call, which resets the idle timer, so in practice the loop
// keeps itself running — this alarm is the safety net that revives it if Chrome
// tears the worker down anyway. 30s is the shortest period Chrome honours.
chrome.alarms.create('keepalive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(() => {
if (timer) tick(); else restart();
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (Object.keys(DEFAULTS).some((k) => k in changes)) restart();
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
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();