Files
market_screener/server/app.ts
T
2026-06-11 19:18:19 -04:00

203 lines
8.1 KiB
TypeScript

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<string, number> = { 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/<domain>/ directory structure
// 2. Move controllers, services, types to the domain
// 3. Create barrel: server/domains/<domain>/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;
}