import { randomBytes } from 'crypto'; import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; // Domain imports import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener'; import { FinanceController } from './domains/finance'; import { PortfolioAdvisor } from './domains/portfolio'; import { CallsController, CalendarService } from './domains/calls'; import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth'; import type { TokenPayload } from './domains/auth'; import { WatchlistController, WatchlistRepository } from './domains/watchlist'; import { NewsController, NewsRepository, NewsPipeline, UniverseProvider, NewsScheduler, EdgarPoller, PrWirePoller, } from './domains/news'; import { DigestController, DigestService } from './domains/digest'; // Shared infrastructure import { YahooFinanceClient, BenchmarkProvider, CatalystCache, LLMAnalyst, MarketCallRepository, PortfolioRepository, SignalSnapshotRepository, createDb, DatabaseConnection, QueryAudit, noopLogger, } from './domains/shared'; interface BuildAppOptions { logger?: boolean; db?: DatabaseConnection; } // ── JWT auth helpers ───────────────────────────────────────────────────────── /** Fastify hook that requires a valid JWT. Attaches payload to req.user. */ function makeAuthGuard(secret: string) { return async (req: FastifyRequest, reply: FastifyReply) => { const header = req.headers['authorization'] ?? ''; if (!header.startsWith('Bearer ')) { return reply.code(401).send({ error: 'Missing token' }); } try { (req as FastifyRequest & { user: TokenPayload }).user = verifyJwt(header.slice(7), secret); } catch { return reply.code(401).send({ error: 'Invalid or expired token' }); } }; } /** Fastify hook that requires a specific role (must run after authGuard). */ function makeRoleGuard(required: 'trader' | 'admin') { return async (req: FastifyRequest, reply: FastifyReply) => { const user = (req as FastifyRequest & { user?: TokenPayload }).user; if (!user) return reply.code(401).send({ error: 'Unauthorized' }); // admin passes every role check; trader passes trader check const roleRank: Record = { viewer: 0, trader: 1, admin: 2 }; if ((roleRank[user.role] ?? 0) < (roleRank[required] ?? 99)) { return reply.code(403).send({ error: 'Forbidden' }); } }; } // ── Adding a new domain ─────────────────────────────────────────────── // 1. Create: server/domains// directory structure // 2. Move controllers, services, types to the domain // 3. Create barrel: server/domains//index.ts // 4. Import from domain and register controller below // ─────────────────────────────────────────────────────────────────────────── export async function buildApp({ logger = true, db: injectedDb }: BuildAppOptions = {}) { const app = Fastify({ logger }); await app.register(cors, { origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', }); // ── Rate limiting — applied globally, tightest on expensive routes ─────── await app.register(rateLimit, { global: false, // opt-in per route via config.rateLimit max: 60, timeWindow: '1 minute', }); // ── API key auth — only enforced when API_KEY env var is set ───────────── const API_KEY = process.env.API_KEY; if (API_KEY) { app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { // Skip auth for health check, OPTIONS preflight, and auth routes if (req.url === '/health' || req.method === 'OPTIONS' || req.url.startsWith('/auth/')) return; const header = req.headers['authorization'] ?? ''; if (header !== `Bearer ${API_KEY}`) { return reply.code(401).send({ error: 'Unauthorized' }); } }); } // Database setup — use injected db (for tests) or create real one const db = injectedDb ?? (() => { const rawDb = createDb(process.env.DB_PATH ?? './market-screener.db'); const audit = new QueryAudit(); return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 }); })(); // ── JWT secret ──────────────────────────────────────────────────────────── const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-in-production'; const authGuard = makeAuthGuard(JWT_SECRET); const traderGuard = makeRoleGuard('trader'); // Services and clients const yahoo = new YahooFinanceClient(); const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger }); const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger }); const advisor = new PortfolioAdvisor(yahoo); const calSvc = new CalendarService(yahoo); const llm = new LLMAnalyst({ logger: noopLogger }); const catalystCache = new CatalystCache({ logger: noopLogger }); // Singleton, cached for 15m // Auth domain — generate a fresh invite code on every boot and print it const INVITE_CODE = randomBytes(12).toString('hex'); // 24-char hex string // Box width based on longest content line (no emoji inside — emoji width is terminal-dependent) const line1 = ` Invite code for this session:`; const line2 = ` ${INVITE_CODE}`; const innerWidth = Math.max(line1.length, line2.length) + 2; const hr = '─'.repeat(innerWidth); const pad = (s: string) => `│ ${s}${' '.repeat(innerWidth - 1 - s.length)}│`; /* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */ console.log(`\n┌${hr}┐`); console.log(pad('')); console.log(pad(line1)); console.log(pad(line2)); console.log(pad('')); console.log(`└${hr}┘\n`); /* eslint-enable no-console */ const userStore = new UserStore(db); const authService = new AuthService(userStore, JWT_SECRET); new AuthController(authService, INVITE_CODE).register(app); // Register controllers // Public routes (GET) remain open; write routes require JWT + trader role const newsRepo = new NewsRepository(db); new ScreenerController( engine, catalystCache, new SignalSnapshotRepository(db), yahoo, newsRepo, ).register(app); new FinanceController(engine, new PortfolioRepository(db), advisor, { authGuard, traderGuard, }).register(app); new CallsController(new MarketCallRepository(db), engine, calSvc, { authGuard, traderGuard, }).register(app); new AnalyzeController(catalystCache, llm).register(app); new WatchlistController(new WatchlistRepository(db), { authGuard }).register(app); // ── News domain (FREE-DATA-STACK) — pipeline + read API + polling ──────── new NewsController(newsRepo, yahoo).register(app); // ── Digest domain (P1.1) — snapshot diff + catalyst join, on demand ────── new DigestController(new DigestService(new SignalSnapshotRepository(db), newsRepo)).register(app); // Polling runs inside the server unless NEWS_POLL=off (use bin/poll-news.ts // from cron instead). Timers are unref'd and cleared on app.close(). if (process.env.NEWS_POLL !== 'off') { const newsLogger = { log: (...args: unknown[]) => app.log.info(args.map(String).join(' ')), warn: (...args: unknown[]) => app.log.warn(args.map(String).join(' ')), write: () => {}, }; const newsScheduler = new NewsScheduler( new NewsPipeline(newsRepo), new UniverseProvider(db), new EdgarPoller(newsLogger), new PrWirePoller(newsLogger), newsLogger, ); app.addHook('onReady', async () => newsScheduler.start()); app.addHook('onClose', async () => newsScheduler.stop()); } app.get('/health', async () => ({ status: 'ok' })); return app; }