The Vest app renews each trading account's own token through the same POST /v3/auth/refresh as the login's (with that account's refresh token in the body). The extension relayed those too, and the dashboard kept whichever token lived longest — so while someone traded in an account on Vest, Me synced with a token that sees that account alone and dropped the other 24 from the list. Extension 0.17.2 skips account renewals. The dashboard also refuses a token that sees one account and no primary (or names that account in its claims), keeps the last account list, and falls back to the login's token it replaced. An account gone from Vest's list no longer leaves a stale position holding the lane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
5.4 KiB
TypeScript
123 lines
5.4 KiB
TypeScript
/**
|
|
* Vest sessions relayed by the extension.
|
|
*
|
|
* Vest logs a person in through Google/Privy in the browser, and refreshes
|
|
* the session with a device key the browser will not export, so this
|
|
* machine cannot log in by itself. Instead the AutoFirmer extension watches
|
|
* the signed-in Vest tab: whenever the Vest app renews its own ~15-minute
|
|
* access token, the extension posts it here (POST /api/vest/session). The
|
|
* Vest client (lib/vest-class.ts) trades with the latest one for its wallet
|
|
* and mints per-account tokens from it.
|
|
*
|
|
* Kept in memory only — never in the DB, the logs, a report or a backup. A
|
|
* restart forgets them; the Vest tab's next renewal (or the extension's
|
|
* next tick) relays a fresh one within minutes.
|
|
*/
|
|
|
|
export interface VestSession {
|
|
accessToken: string;
|
|
/** Epoch ms the access token stops working. */
|
|
expiresAt: number;
|
|
/** The wallet the Vest login belongs to, lower-case. */
|
|
primaryAddr: string;
|
|
userId: string;
|
|
receivedAt: number;
|
|
}
|
|
|
|
/** What the dashboard and the extension may see of a session: never the token. */
|
|
export interface VestSessionInfo {
|
|
primaryAddr: string;
|
|
userId: string;
|
|
expiresAt: number;
|
|
receivedAt: number;
|
|
}
|
|
|
|
const g = globalThis as typeof globalThis & {
|
|
__vestSessions?: Map<string, VestSession>;
|
|
__vestPrevious?: Map<string, VestSession>;
|
|
__vestRefused?: Map<string, number>;
|
|
};
|
|
|
|
function store(): Map<string, VestSession> {
|
|
return (g.__vestSessions ??= new Map());
|
|
}
|
|
|
|
/** The session each wallet's current one replaced — what a refused token
|
|
* falls back to. */
|
|
function previous(): Map<string, VestSession> {
|
|
return (g.__vestPrevious ??= new Map());
|
|
}
|
|
|
|
/** Tokens the client found to be one account's, not the login's (by their
|
|
* tail — enough to tell them apart, never the whole token), with when. */
|
|
function refused(): Map<string, number> {
|
|
return (g.__vestRefused ??= new Map());
|
|
}
|
|
|
|
const REFUSED_KEEP = 50;
|
|
|
|
/** Shortest life a relayed token must have left to be taken. */
|
|
const MIN_REMAINING_MS = 30_000;
|
|
/** Longest a relayed token may claim to live — a Vest access token is
|
|
* ~15 minutes; anything far beyond that is not one. */
|
|
const MAX_REMAINING_MS = 6 * 60 * 60_000;
|
|
|
|
/** Take a relayed session. Returns why it was refused, or null. A token for
|
|
* a wallet only ever replaces one that expires sooner. */
|
|
export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null {
|
|
const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : '';
|
|
const expiresAt = Number(input.expiresAt);
|
|
const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : '';
|
|
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT';
|
|
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
|
|
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
|
|
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
|
|
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
|
|
const prev = store().get(addr);
|
|
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
|
|
if (prev && prev.accessToken !== token) previous().set(addr, prev);
|
|
store().set(addr, {
|
|
accessToken: token,
|
|
expiresAt,
|
|
primaryAddr: addr,
|
|
userId: typeof input.userId === 'string' ? input.userId : '',
|
|
receivedAt: now,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
/** The live session for a wallet, or null when none has been relayed or it
|
|
* is about to expire. */
|
|
export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null {
|
|
const s = store().get(primaryAddr.trim().toLowerCase());
|
|
return s && s.expiresAt - now >= minRemainingMs ? s : null;
|
|
}
|
|
|
|
/** The client found the wallet's token to be one account's own (see
|
|
* accountScopedToken): drop it, and take it no more if it's relayed again —
|
|
* the login's own token, when the Vest app next renews it, takes its place. */
|
|
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
|
|
const key = primaryAddr.trim().toLowerCase();
|
|
refused().set(token.slice(-32), now);
|
|
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
|
|
if (store().get(key)?.accessToken !== token) return;
|
|
store().delete(key);
|
|
// Back to the token it replaced, if that one is still good.
|
|
const before = previous().get(key);
|
|
previous().delete(key);
|
|
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
|
|
}
|
|
|
|
/** Drop a wallet's session — the gateway refused it. */
|
|
export function dropVestSession(primaryAddr: string, token: string): void {
|
|
const key = primaryAddr.trim().toLowerCase();
|
|
if (store().get(key)?.accessToken === token) store().delete(key);
|
|
}
|
|
|
|
/** Every relayed session, newest first, without tokens. */
|
|
export function listVestSessions(): VestSessionInfo[] {
|
|
return [...store().values()]
|
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
|
.map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt }));
|
|
}
|