diff --git a/app/api/autobuyer/otp/route.ts b/app/api/autobuyer/otp/route.ts new file mode 100644 index 0000000..760e8d9 --- /dev/null +++ b/app/api/autobuyer/otp/route.ts @@ -0,0 +1,35 @@ +import { getSetting } from '@/lib/db'; +import { corsJson, corsPreflight } from '../cors'; + +/** The extension's 2FA lookup. It knows the tab's host; this dashboard knows + * which person it is (the master-issued `instance_id`) and where the master + * is, so it asks the master for this person's codes on that site and passes + * them through. + * + * Never an error status: no link to the master, or a master that can't be + * reached, is just "no codes" with a reason — the extension shows nothing. */ +export async function GET(request: Request) { + const host = new URL(request.url).searchParams.get('host')?.trim().toLowerCase(); + if (!host) return corsJson({ codes: [], reason: 'host is required' }); + + const master = getSetting('master_dashboard_url')?.trim().replace(/\/+$/, ''); + const person = getSetting('instance_id')?.trim(); + if (!master || !person) return corsJson({ codes: [], reason: 'not linked to master' }); + + const url = `${master}/api/otp?person=${encodeURIComponent(person)}&host=${encodeURIComponent(host)}`; + try { + const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(5000) }); + if (!res.ok) return corsJson({ codes: [], reason: `master answered ${res.status}` }); + const data = (await res.json()) as { codes?: unknown[]; serverTime?: number; scope?: string }; + // A master from before per-person lookups ignores ?person= and answers + // with every stored code — never show those. + if (data.scope !== 'person') return corsJson({ codes: [], reason: 'master is too old for 2FA lookups' }); + return corsJson({ codes: data.codes ?? [], serverTime: data.serverTime ?? Date.now() }); + } catch (err) { + return corsJson({ codes: [], reason: `master unreachable: ${(err as Error).message}` }); + } +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/extension/README.md b/extension/README.md index ad5decb..af7822e 100644 --- a/extension/README.md +++ b/extension/README.md @@ -58,6 +58,27 @@ going back to ``: it would put every tab you visit in reach, and limit firm needs its host in *both* lists — `host_permissions` and the `content_scripts` matches — or captchas on that site will have no callback to fire. +## 2FA codes + +On a firm site, the extension shows the machine's person's authenticator code +for that site in a small chip in the bottom-right corner, with a Copy button +(minimise it to a "2FA" pill; that sticks for the tab's session). + +``` +extension --GET /api/autobuyer/otp?host=--> dashboard +dashboard --GET /api/otp?person=&host=--> master +``` + +The master holds the secrets and matches the host against each firm's **Site** +link (Firms page), then returns the codes assigned to this person and that firm +(Codes page). The chip only needs a code to be assigned — it doesn't wait for a +code field. It stays hidden for a minute after the runner last located +something, so automated clicks never land on it. + +A new 2FA site needs its host in `host_permissions` (not the captcha +`content_scripts` list), a Site link on its firm on the master, and its codes +assigned on the master's Codes page. + ## Notes - The extension never captures the dashboard's own pages — otherwise it would diff --git a/extension/background.js b/extension/background.js index 3e9dfc1..b046587 100644 --- a/extension/background.js +++ b/extension/background.js @@ -10,6 +10,8 @@ * * 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. */ const DEFAULTS = { @@ -900,6 +902,7 @@ async function serveLocateRequest(cfg) { 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, @@ -1176,6 +1179,271 @@ async function tick() { } catch (err) { await setState({ connected: true, error: err.message }); } + + try { + await maybeShowOtp(cfg); + } catch (err) { + await setState({ connected: true, error: `2FA chip: ${err.message}` }); + } +} + +// ── 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 = ` + +
+
2FA code
+
+
+ `; + + 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 ─────────────────────────────────────────────── diff --git a/extension/manifest.json b/extension/manifest.json index 7c0d265..22e30ee 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,17 +1,18 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.15.0", + "version": "0.16.0", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "update_url": "https://ext.juicerroom.com/updates.xml", - "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.", + "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, and shows the 2FA code for the firm site you are on.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ "https://*.tradeify.co/*", "https://*.lucidtrading.com/*", "https://*.topstep.com/*", + "https://*.apextraderfunding.com/*", "http://localhost/*", "http://127.0.0.1/*" ], diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 598cb77..4fa15e1 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -385,6 +385,9 @@ export interface CatalogFirm { * by root, for every account type (absent = no limit beyond the type's * max contracts). */ instrumentLimits?: Record; + /** The firm's website ('' = none; absent from older masters). The master + * matches the extension's tab against it to find a person's 2FA code. */ + siteUrl?: string; /** Older masters' way to exclude roots; newer ones derive it from * `instruments`. */ bannedSymbols: string[];