phase-10.5: market screener ui enhancements
This commit is contained in:
+69
-5
@@ -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' }));
|
||||
|
||||
Reference in New Issue
Block a user