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:
co-authored by
Claude Opus 5.5
parent
58e7414849
commit
d6bb5a7221
@@ -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();
|
||||
}
|
||||
@@ -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`
|
||||
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
|
||||
|
||||
- The extension never captures the dashboard's own pages — otherwise it would
|
||||
|
||||
@@ -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 = `
|
||||
<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,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/*"
|
||||
],
|
||||
|
||||
@@ -385,6 +385,9 @@ export interface CatalogFirm {
|
||||
* by root, for every account type (absent = no limit beyond the type's
|
||||
* max contracts). */
|
||||
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
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
|
||||
Reference in New Issue
Block a user