203 lines
8.1 KiB
TypeScript
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;
|
|
}
|