Show the person's 2FA code on the firm site the extension is on

On a firm site, the extension draws a small corner chip with this
machine's person's authenticator code for that site, a countdown, and a
Copy button. It can be minimised to a "2FA" pill for the tab's session.

- /api/autobuyer/otp?host= relays to the master's
  /api/otp?person=<instance_id>&host=, which matches the host to a firm's
  Site link and the person's assigned codes. No master link, a master
  that can't be reached, or no match all come back as no codes. An answer
  without scope: 'person' is refused: an older master ignores ?person=
  and would return every stored code.
- The chip lives in a shadow root, counts down on its own, refetches
  when a code expires, and stays hidden for a minute after the runner
  last located something, so automated clicks never land on it. Copy
  uses execCommand inside the click, since the async Clipboard API can
  stall on a permission prompt, and leaves focus in the site's code field.
- Apex (*.apextraderfunding.com) joins host_permissions, which also
  brings it into capture like the other firm sites. Version 0.16.0.
- CatalogFirm mirrors the master's new optional siteUrl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-29 17:19:32 -05:00
co-authored by Claude Opus 5.5
parent 58e7414849
commit d6bb5a7221
5 changed files with 330 additions and 2 deletions
+35
View File
@@ -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();
}
+21
View File
@@ -58,6 +58,27 @@ going back to `<all_urls>`: it would put every tab you visit in reach, and limit
firm needs its host in *both* lists — `host_permissions` and the `content_scripts` 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. 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=<tab host>--> dashboard
dashboard --GET <master>/api/otp?person=<instance_id>&host=<tab 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 ## Notes
- The extension never captures the dashboard's own pages — otherwise it would - The extension never captures the dashboard's own pages — otherwise it would
+268
View File
@@ -10,6 +10,8 @@
* *
* The locate queue is served whenever clicks are waiting. The AutoBuyer is * The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one. * 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 = { const DEFAULTS = {
@@ -900,6 +902,7 @@ async function serveLocateRequest(cfg) {
let error = null; let error = null;
try { try {
let { tab, opened } = await resolveLocateTab(cfg, request); 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 // 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, // are already there — reloading would throw away page state for nothing,
@@ -1176,6 +1179,271 @@ async function tick() {
} catch (err) { } catch (err) {
await setState({ connected: true, error: err.message }); 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 = `
<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 ─────────────────────────────────────────────── // ── Keeping ourselves current ───────────────────────────────────────────────
+3 -2
View File
@@ -1,17 +1,18 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.15.0", "version": "0.16.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",
"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"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
"https://*.tradeify.co/*", "https://*.tradeify.co/*",
"https://*.lucidtrading.com/*", "https://*.lucidtrading.com/*",
"https://*.topstep.com/*", "https://*.topstep.com/*",
"https://*.apextraderfunding.com/*",
"http://localhost/*", "http://localhost/*",
"http://127.0.0.1/*" "http://127.0.0.1/*"
], ],
+3
View File
@@ -385,6 +385,9 @@ export interface CatalogFirm {
* by root, for every account type (absent = no limit beyond the type's * by root, for every account type (absent = no limit beyond the type's
* max contracts). */ * max contracts). */
instrumentLimits?: Record<string, number>; instrumentLimits?: Record<string, number>;
/** 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 /** Older masters' way to exclude roots; newer ones derive it from
* `instruments`. */ * `instruments`. */
bannedSymbols: string[]; bannedSymbols: string[];