phase-10.5: market screener ui enhancements

This commit is contained in:
saikiranvella
2026-06-09 01:21:02 -04:00
parent 3c321a4a79
commit 5c8cd8935a
45 changed files with 3054 additions and 539 deletions
+69 -5
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
@@ -7,6 +8,8 @@ import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains
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';
// Shared infrastructure
import {
@@ -27,6 +30,36 @@ interface BuildAppOptions {
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
@@ -51,8 +84,8 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const API_KEY = process.env.API_KEY;
if (API_KEY) {
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
// Skip auth for health check and OPTIONS preflight
if (req.url === '/health' || req.method === 'OPTIONS') return;
// 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' });
@@ -64,11 +97,16 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
const db =
injectedDb ??
(() => {
const rawDb = createDb();
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 });
@@ -78,10 +116,36 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
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)}`;
console.log(`\n┌${hr}`);
console.log(pad(''));
console.log(pad(line1));
console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
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
new ScreenerController(engine, catalystCache).register(app);
new FinanceController(engine, new PortfolioRepository(db), advisor).register(app);
new CallsController(new MarketCallRepository(db), engine, calSvc).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);
app.get('/health', async () => ({ status: 'ok' }));