import type { Express, Request, Response } from "express"; import { createServer } from "node:http"; import type { Server } from "node:http"; // eslint-disable-next-line @typescript-eslint/no-var-requires import * as session from "express-session"; // eslint-disable-next-line @typescript-eslint/no-var-requires import MemoryStoreModule from "memorystore"; const MemoryStore = MemoryStoreModule(session.default || session); import { SYMBOLS, computeExpirations as mockExpirations, computeGexProfile as mockGexProfile, computeScreener as mockScreener, computeSummary as mockSummary, } from "./marketData"; import { oratsClient } from "./oratsClient"; import { tanukiClient } from "./tanukiClient"; import { registerAuthRoutes } from "./authRoutes"; import { initDb, getAllCustomSymbols, createCustomSymbol, deleteCustomSymbol } from "./db"; import { rateLimit, securityHeaders } from "./middleware"; const SessionMemoryStore = new MemoryStore({ checkPeriod: 86400000 }); // Built-in tickers from marketData const BUILTIN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker)); // Cache of all symbols (built-in + custom) — refreshed on demand let cachedAllSymbols: any[] | null = null; let cachedKnownTickers: Set | null = null; async function refreshSymbolCache() { const customSymbolsList = await getAllCustomSymbols().catch(() => []); // Merge built-in + custom, deduplicate by ticker const builtInMap = new Map(SYMBOLS.map(s => [s.ticker, { ...s, custom: false }])); for (const cs of customSymbolsList) { if (!builtInMap.has(cs.ticker)) { builtInMap.set(cs.ticker, { ticker: cs.ticker, name: cs.name, type: cs.type as "index" | "etf" | "equity", sector: cs.sector || "Custom", custom: true, }); } } cachedAllSymbols = Array.from(builtInMap.values()); cachedKnownTickers = new Set(cachedAllSymbols.map(s => s.ticker)); } function getKnownTickers(): Set { return cachedKnownTickers || BUILTIN_TICKERS; } function getAllSymbols(): any[] { return cachedAllSymbols || SYMBOLS.map(s => ({ ...s, custom: false })); } function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown) { const raw = String(req.params.symbol || "").toUpperCase(); if (!getKnownTickers().has(raw)) { res.status(404).json({ error: `Unknown symbol: ${raw}` }); return; } try { res.json(fn(raw)); } catch (err) { res.status(500).json({ error: "Internal server error" }); } } export async function registerRoutes(httpServer: Server, app: Express): Promise { await initDb(); await refreshSymbolCache(); // ── Security Headers ────────────────────────────────────────────── app.use(securityHeaders); // ── Rate Limiting ───────────────────────────────────────────────── // General rate limit: 500 requests per 15 minutes (relaxed for dev) // Home subnet (192.168.x.x) is whitelisted in middleware.ts app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 500 })); // Auth-specific rate limit: 50 requests per 15 minutes (relaxed for dev) app.use("/api/auth/", rateLimit({ windowMs: 15 * 60 * 1000, max: 50 })); // ── Session Configuration ───────────────────────────────────────── app.use( (session.default || session)({ store: new MemoryStore({ checkPeriod: 86400000 }), secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me", resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === "production", httpOnly: true, // Prevent XSS from accessing session cookie sameSite: "strict", // Prevent CSRF maxAge: 24 * 60 * 60 * 1000, // 24 hours }, }), ); // ── Body Parsing ────────────────────────────────────────────────── app.use(express.json({ limit: "1mb" })); app.use(express.urlencoded({ extended: false, limit: "1mb" })); // Auth routes registerAuthRoutes(app); // Public API routes app.get("/api/symbols", async (_req, res) => { await refreshSymbolCache(); res.json(getAllSymbols()); }); // Add a custom symbol app.post("/api/symbols", async (req, res) => { const { ticker, name, type, sector } = req.body as Record; if (!ticker || !name) { return res.status(400).json({ error: "ticker and name are required" }); } const normalizedTicker = ticker.trim().toUpperCase(); if (normalizedTicker.length > 8) { return res.status(400).json({ error: "ticker must be 8 characters or less" }); } try { const symbol = await createCustomSymbol({ ticker: normalizedTicker, name: name.trim(), type: (type as any) || "etf", sector: sector?.trim() || null, }); await refreshSymbolCache(); res.json({ ok: true, symbol }); } catch (err: any) { if (err.code === '23505') { return res.status(409).json({ error: `Symbol ${normalizedTicker} already exists` }); } console.error("Error creating custom symbol:", err); res.status(500).json({ error: "Failed to create symbol" }); } }); // Delete a custom symbol app.delete("/api/symbols/:ticker", async (req, res) => { const ticker = String(req.params.ticker || "").toUpperCase(); if (BUILTIN_TICKERS.has(ticker)) { return res.status(400).json({ error: "Cannot remove built-in symbol" }); } try { const deleted = await deleteCustomSymbol(ticker); if (!deleted) { return res.status(404).json({ error: `Custom symbol ${ticker} not found` }); } await refreshSymbolCache(); res.json({ ok: true }); } catch (err) { console.error("Error deleting custom symbol:", err); res.status(500).json({ error: "Failed to delete symbol" }); } }); // ORATS status - hide sensitive info app.get("/api/orats/status", (_req, res) => { res.json({ configured: oratsClient.isConfigured(), baseUrl: oratsClient.isConfigured() ? "https://api.orats.io/datav2" : "not-configured", }); }); // Save ORATS API key + base URL to session app.post("/api/orats/config", (req, res) => { const { apiKey, baseUrl } = req.body as Record; const session = req.session as Record; if (!apiKey || typeof apiKey !== "string") { return res.status(400).json({ error: "API key is required" }); } const sanitizedApiKey = apiKey.trim().replace(/[<>]/g, ""); session.oratsApiKey = sanitizedApiKey; // Update the orats client with the new key oratsClient.setApiKey(sanitizedApiKey); if (baseUrl && typeof baseUrl === "string") { oratsClient.setBaseUrl(baseUrl); } res.json({ ok: true, configured: oratsClient.isConfigured() }); }); // Clear ORATS API key from session app.delete("/api/orats/config", (req, res) => { const session = req.session as Record; delete session.oratsApiKey; oratsClient.setApiKey(""); res.json({ ok: true, configured: oratsClient.isConfigured() }); }); // ── Tanuki Trades Integration ───────────────────────────────────── // Tanuki status app.get("/api/tanuki/status", (_req, res) => { res.json({ configured: tanukiClient.isConfigured(), }); }); // Save Tanuki config app.post("/api/tanuki/config", (req, res) => { const { email, tvUser } = req.body as Record; if (!email || !tvUser) { return res.status(400).json({ error: "email and tvUser are required" }); } tanukiClient.setConfig(email.trim(), tvUser.trim()); res.json({ ok: true, configured: tanukiClient.isConfigured() }); }); // Clear Tanuki config app.delete("/api/tanuki/config", (_req, res) => { try { const fs = require("fs"); const path = require("path"); const configPath = path.join(__dirname, "..", "tanuki_config.json"); if (fs.existsSync(configPath)) fs.unlinkSync(configPath); } catch { /* ignore */ } res.json({ ok: true, configured: tanukiClient.isConfigured() }); }); // Fetch Tanuki levels for a symbol — falls back to ORATS-derived levels if Tanuki not configured app.get("/api/tanuki/:symbol", async (req, res) => { const symbol = String(req.params.symbol || "").toUpperCase(); try { let levels; if (tanukiClient.isConfigured()) { levels = await tanukiClient.getLevels(symbol); } else { // Fall back to ORATS-derived Tanuki levels levels = await oratsClient.computeTanukiLevels(symbol); } res.json(levels); } catch (err: any) { // If Tanuki fails, try ORATS as fallback if (tanukiClient.isConfigured() && oratsClient.isConfigured()) { try { const fallback = await oratsClient.computeTanukiLevels(symbol); res.json(fallback); return; } catch { /* ignore */ } } console.error(`Tanuki error for ${symbol}:`, err); res.status(502).json({ error: err.message || "Failed to fetch levels" }); } }); // Fetch Tanuki levels for multiple symbols — uses ORATS app.get("/api/tanuki", async (req, res) => { const symbols = (req.query.symbols as string) ?.split(",").map(s => s.trim().toUpperCase()).filter(Boolean) || ["SPX", "QQQ", "GLD", "SLV", "USO"]; try { const results: Record = {}; await Promise.all(symbols.map(async (sym) => { try { results[sym] = await oratsClient.computeTanukiLevels(sym); } catch (err: any) { results[sym] = { error: err.message || "Failed to fetch" }; } })); res.json(results); } catch (err: any) { console.error("Tanuki multi-fetch error:", err); res.status(502).json({ error: err.message || "Failed to fetch Tanuki data" }); } }); // Market data routes app.get("/api/market/:symbol/summary", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { const summary = oratsClient.isConfigured() ? await oratsClient.computeSummary(ticker) : mockSummary(ticker); res.json(summary); } catch (err) { console.error(`ORATS summary error for ${ticker}:`, err); res.json(mockSummary(ticker)); } }); app.get("/api/market/:symbol/gex", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { const gex = oratsClient.isConfigured() ? await oratsClient.computeGexProfile(ticker) : mockGexProfile(ticker); res.json(gex); } catch (err) { console.error(`ORATS GEX error for ${ticker}:`, err); res.json(mockGexProfile(ticker)); } }); app.get("/api/market/:symbol/expirations", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { const exp = oratsClient.isConfigured() ? await oratsClient.computeExpirations(ticker) : mockExpirations(ticker); res.json(exp); } catch (err) { console.error(`ORATS expirations error for ${ticker}:`, err); res.json(mockExpirations(ticker)); } }); app.get("/api/screener", async (_req, res) => { try { await refreshSymbolCache(); const allSymbols = getAllSymbols(); if (oratsClient.isConfigured()) { const summaries = await Promise.all( allSymbols.map((s) => oratsClient.computeSummary(s.ticker)), ); const screenerData = summaries.map((summary, i) => { const symbol = allSymbols[i]; return { ticker: symbol.ticker, name: symbol.name, spot: summary.spot, spotChangePct: summary.spotChangePct, netGex: summary.netGex, gammaRegime: summary.gammaRegime, ivRank: summary.ivRank, ivx: summary.ivx, callWall: summary.callWall, putWall: summary.putWall, distanceToCallWall: ((summary.callWall - summary.spot) / summary.spot) * 100, distanceToPutWall: ((summary.putWall - summary.spot) / summary.spot) * 100, expectedMovePct: summary.expectedMovePct, skew: summary.skew, zeroDteNetGex: summary.netGex, }; }); res.json(screenerData); } else { res.json(mockScreener()); } } catch (err) { console.error("ORATS screener error:", err); res.json(mockScreener()); } }); // ── Futures Levels (Tanuki-style: ETF gamma → futures equivalents) ── app.get("/api/futures/:symbol/levels", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } // Import conversions from shared schema const { FUTURES_CONVERSIONS } = await import("@shared/schema"); const conversion = FUTURES_CONVERSIONS.find((c) => c.sourceTicker === ticker); if (!conversion) { return res.status(400).json({ error: `No futures mapping for ${ticker}. Supported: ${FUTURES_CONVERSIONS.map((c) => c.sourceTicker).join(", ")}` }); } try { const [summary, gex] = await Promise.all([ oratsClient.isConfigured() ? oratsClient.computeSummary(ticker) : Promise.resolve(mockSummary(ticker)), oratsClient.isConfigured() ? oratsClient.computeGexProfile(ticker) : Promise.resolve(mockGexProfile(ticker)), ]); const { multiplier } = conversion; const sourceSpot = summary.spot; // Get top strikes by absolute net GEX const topStrikes = [...gex.bars] .map((b) => ({ sourceStrike: b.strike, futuresStrike: Math.round(b.strike * multiplier * 100) / 100, netGex: b.netGex, magnitude: Math.abs(b.netGex), })) .sort((a, b) => b.magnitude - a.magnitude) .slice(0, 12); const futuresLevel = { sourceTicker: ticker, targetTicker: conversion.targetTicker, targetName: conversion.targetName, sourceSpot, futuresSpot: Math.round(sourceSpot * multiplier * 100) / 100, hvl: Math.round(gex.hvl * multiplier * 100) / 100, callWall: Math.round(gex.callWall * multiplier * 100) / 100, putWall: Math.round(gex.putWall * multiplier * 100) / 100, sourceHvl: gex.hvl, sourceCallWall: gex.callWall, sourcePutWall: gex.putWall, topStrikes, asOf: gex.asOf, }; res.json(futuresLevel); } catch (err) { console.error(`Futures levels error for ${ticker}:`, err); res.status(500).json({ error: "Failed to compute futures levels" }); } }); return httpServer; } // Import express for body parsing import express from "express";