/** Shared Tradingbot status contract. The browser and the API proxy both use this. */ export const TRADING_BOT_STATUS_PATH = "/bot/api/status"; export const TRADING_BOT_PROXY_PATH = "/api/trading-bot/status"; export const TRADING_BOT_POLL_INTERVAL_MS = 20_000; export const TRADING_BOT_POLL_MAX_BACKOFF_MS = 120_000; export const TRADING_BOT_STATUS_TIMEOUT_MS = 5_000; export const TRADING_BOT_STATUS_MAX_BYTES = 65_536; const MAX_ORIGIN_LENGTH = 200; const MAX_ERROR_LENGTH = 80; const MAX_ADAPTER_COUNT = 10_000; const LIVE_TOKENS = new Set(["live", "live_trading", "real", "production"]); const PAPER_TOKENS = new Set(["paper", "paper_sim", "paper_trading", "simulation", "sim"]); const DRY_RUN_TOKENS = new Set(["dry_run", "dryrun", "dry"]); const MODE_STRING_KEYS = [ "mode", "trading_mode", "tradingMode", "run_mode", "runMode", "execution_mode", "executionMode", "badge", "status", ] as const; const NESTED_KEYS = ["data", "bot", "state", "result", "status"] as const; const ADAPTER_COUNT_KEYS = [ "adapterCount", "adapter_count", "adaptersCount", "adapters_count", "n_adapters", ] as const; const ERROR_KEYS = [ "lastError", "last_error", "last_error_message", "errorMessage", "error", ] as const; const EMPTY_ERRORS = new Set(["ok", "none", "null", "undefined", "n/a", "-"]); const UNSAFE_ERROR = /bearer\s|api[_-]?key|password|secret|token|authorization|cookie|https?:\/\//i; export type TradingBotKind = "live" | "paper" | "dry_run"; export type TradingBotStatusView = { mode: "live" | "paper"; kind: TradingBotKind; adapterCount: number | null; lastError: string | null; }; export type TradingBotOrigin = | { kind: "unset" } | { kind: "invalid" } | { kind: "origin"; origin: string }; export type TradingBotSnapshot = | { state: "unconfigured" } | { state: "offline" } | { state: "unknown" } | { state: "ready"; status: TradingBotStatusView }; /** Accept a bare http(s) origin. Credentials and other schemes are rejected. */ export function parseTradingBotOrigin(value: string | undefined): TradingBotOrigin { const trimmed = value?.trim() ?? ""; if (!trimmed) return { kind: "unset" }; if (trimmed.length > MAX_ORIGIN_LENGTH || /\s/.test(trimmed) || hasControlChar(trimmed)) { return { kind: "invalid" }; } let url: URL; try { url = new URL(trimmed); } catch { return { kind: "invalid" }; } if (url.protocol !== "http:" && url.protocol !== "https:") return { kind: "invalid" }; if (url.username || url.password || !url.hostname) return { kind: "invalid" }; return { kind: "origin", origin: url.origin }; } /** Throw when the operator set a value that must not be fetched. Unset stays unset. */ export function resolveTradingBotOrigin(value: string | undefined): string | undefined { const parsed = parseTradingBotOrigin(value); if (parsed.kind === "invalid") throw new Error("Invalid TRADING_BOT_URL"); return parsed.kind === "origin" ? parsed.origin : undefined; } export function tradingBotStatusUrl(origin: string): string { return `${origin}${TRADING_BOT_STATUS_PATH}`; } export function tradingBotBackoffMs( intervalMs: number, failureCount: number, maxBackoffMs: number, ): number { const exponent = Math.min(Math.max(failureCount - 1, 0), 6); return Math.min(intervalMs * 2 ** exponent, maxBackoffMs); } /** Map a Tradingbot `/bot/api/status` body. Unknown or conflicting modes return null. */ export function mapTradingBotStatus(body: unknown): TradingBotStatusView | null { const record = asRecord(body); if (!record) return null; const kind = resolveKind(readSignals(record)); if (!kind) return null; return { mode: kind === "live" ? "live" : "paper", kind, adapterCount: readAdapterCount(record), lastError: readLastError(record), }; } /** Accept a proxy payload field. Drops unsafe error text instead of dropping the mode. */ export function coerceTradingBotStatusView(value: unknown): TradingBotStatusView | null { const record = asRecord(value); if (!record) return null; const { mode, kind } = record; if (mode !== "live" && mode !== "paper") return null; if (kind !== "live" && kind !== "paper" && kind !== "dry_run") return null; if ((mode === "live") !== (kind === "live")) return null; if (record.adapterCount !== null && parseCount(record.adapterCount) === null) return null; if (record.lastError !== null && typeof record.lastError !== "string") return null; return { mode, kind, adapterCount: record.adapterCount === null ? null : parseCount(record.adapterCount), lastError: record.lastError === null ? null : sanitizeTradingBotError(record.lastError), }; } export function interpretTradingBotProxyResponse( status: number, body: unknown, ): TradingBotSnapshot { const record = asRecord(body); if (status === 200 && record?.configured === false) return { state: "unconfigured" }; if (record?.configured === true && record.reachable === false) return { state: "offline" }; if (status === 200 && record?.configured === true && record.reachable === true) { if (record.status == null) return { state: "unknown" }; const view = coerceTradingBotStatusView(record.status); return view ? { state: "ready", status: view } : { state: "unknown" }; } return { state: "offline" }; } /** Keep a short operator-facing error. Drop secrets, URLs, and empty placeholders. */ export function sanitizeTradingBotError(value: unknown): string | null { if (typeof value !== "string") return null; const cleaned = stripControlChars(value).replace(/\s+/g, " ").trim(); if (!cleaned || EMPTY_ERRORS.has(cleaned.toLowerCase())) return null; if (UNSAFE_ERROR.test(cleaned)) return null; if (cleaned.length <= MAX_ERROR_LENGTH) return cleaned; return `${cleaned.slice(0, MAX_ERROR_LENGTH - 1)}…`; } function readSignals(record: Record): TradingBotKind[] { const signals: TradingBotKind[] = []; pushSignals(record, signals); if (signals.length > 0) return signals; for (const key of NESTED_KEYS) { const nested = asRecord(record[key]); if (nested) pushSignals(nested, signals); } return signals; } function pushSignals(record: Record, signals: TradingBotKind[]): void { for (const key of MODE_STRING_KEYS) { const value = record[key]; if (typeof value !== "string") continue; const kind = classifyModeToken(value); if (kind) signals.push(kind); } if (record.live === true || record.is_live === true || record.isLive === true) { signals.push("live"); } if (record.dry_run === true || record.dryRun === true) signals.push("dry_run"); if ( record.paper === true || record.is_paper === true || record.isPaper === true || record.paper_trading === true || record.paperTrading === true ) { signals.push("paper"); } } function resolveKind(signals: TradingBotKind[]): TradingBotKind | null { const unique = new Set(signals); const live = unique.has("live"); const paper = unique.has("paper"); const dryRun = unique.has("dry_run"); if (live && (paper || dryRun)) return null; if (dryRun) return "dry_run"; if (paper) return "paper"; if (live) return "live"; return null; } function classifyModeToken(value: string): TradingBotKind | null { const token = value .trim() .toLowerCase() .replace(/[\s-]+/g, "_"); if (LIVE_TOKENS.has(token)) return "live"; if (DRY_RUN_TOKENS.has(token)) return "dry_run"; if (PAPER_TOKENS.has(token)) return "paper"; return null; } function readAdapterCount(record: Record): number | null { const found = readOptional(record, adapterCountIn); return found === undefined ? null : found; } function adapterCountIn(record: Record): number | null | undefined { for (const key of ADAPTER_COUNT_KEYS) { if (!Object.hasOwn(record, key)) continue; return parseCount(record[key]); } if (Object.hasOwn(record, "adapters") && Array.isArray(record.adapters)) { return parseCount(record.adapters.length); } return undefined; } function readLastError(record: Record): string | null { const found = readOptional(record, lastErrorIn); return found === undefined ? null : found; } function lastErrorIn(record: Record): string | null | undefined { for (const key of ERROR_KEYS) { if (!Object.hasOwn(record, key)) continue; return sanitizeTradingBotError(record[key]); } return undefined; } function readOptional( record: Record, read: (row: Record) => T | undefined, ): T | undefined { const direct = read(record); if (direct !== undefined) return direct; for (const key of NESTED_KEYS) { const nested = asRecord(record[key]); if (!nested) continue; const value = read(nested); if (value !== undefined) return value; } return undefined; } function parseCount(value: unknown): number | null { if (typeof value === "string" && !/^\d+$/.test(value.trim())) return null; const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; if (!Number.isInteger(numeric) || numeric < 0 || numeric > MAX_ADAPTER_COUNT) return null; return numeric; } function hasControlChar(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); if (code < 32 || code === 127) return true; } return false; } function stripControlChars(value: string): string { let out = ""; for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); const char = value[index]; if (char === undefined) continue; out += code < 32 || code === 127 ? " " : char; } return out; } function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as Record; }