From d1556f2a679d207d89b503d3c83a7af0b208adcb Mon Sep 17 00:00:00 2001 From: Sai Kiran Vella Date: Fri, 5 Jun 2026 23:02:21 -0400 Subject: [PATCH] phase-8g: rate limiting and update readme doc --- CLAUDE.md | 6 +- README.md | 425 +++++++++++++--------- package-lock.json | 31 ++ package.json | 1 + server/app.ts | 23 +- server/controllers/analyze.controller.ts | 6 +- server/controllers/screener.controller.ts | 12 +- 7 files changed, 320 insertions(+), 184 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e69b5f..b470f19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ ui/ ← SvelteKit dashboard (lives inside this repo, not a market-calls.json ← persisted market thesis calls (written by MarketCallRepository) portfolio.json ← user's holdings: ticker, shares, costBasis, source, type -.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY +.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY, API_KEY (optional — enables Bearer auth on all routes) ``` --- @@ -626,9 +626,9 @@ Add one Fastify `inject()` smoke test per route using a fixture for `ScreenerEng `BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 2–4s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale. -#### 8g — Rate limiting + API key auth +#### 8g — Rate limiting + API key auth ✅ -Add `@fastify/rate-limit` on `/api/screen` and `/api/analyze` (e.g. 10 req/min per IP). Add a simple `Authorization: Bearer ` check against an `API_KEY` env var as middleware in `server/app.ts`. Both are single-digit line additions. +`@fastify/rate-limit` registered globally in `server/app.ts` (`global: false`, opt-in per route). `/api/screen`, `/api/screen/catalysts`, and `/api/analyze` each carry `config: { rateLimit: { max: 10, timeWindow: '1 minute' } }`. API key enforced via `onRequest` hook when `API_KEY` env var is set (`Authorization: Bearer `); `/health` and OPTIONS are exempt. **Requires `npm install` after adding `@fastify/rate-limit` to dependencies (done in package.json).** #### 8h — Extract `CalendarService` diff --git a/README.md b/README.md index 86917af..bbe041d 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,141 @@ # Market Screener -A Node.js stock screener and personal finance assistant. Screens stocks, ETFs, and bonds using live Yahoo Finance data and scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares both to give you an honest signal. - -Comes with a **Fastify API server** and a companion **SvelteKit dashboard** (`../market-screener-ui`) for an interactive UI, or use it as a CLI to generate HTML reports. +A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares them to produce an actionable signal. Comes with a live SvelteKit dashboard. --- -## Quick Start +## Table of Contents + +- [Developer Setup](#developer-setup) +- [Environment Variables](#environment-variables) +- [Commands](#commands) +- [Running Tests](#running-tests) +- [Project Structure](#project-structure) +- [User Guide](#user-guide) + +--- + +## Developer Setup + +### Prerequisites + +- Node.js 20+ +- npm 10+ + +### Install ```bash -# API + Dashboard (recommended) +# Install server dependencies npm install -cd ../market-screener-ui && npm install && cd ../market_screener -npm run dev # starts API on :3000 + UI on :5173 -# open http://localhost:5173 -# CLI only -npm start # screen today's news catalyst tickers → screener-report.html +# Install UI dependencies (first time only) +npm run ui:install +``` + +### Start + +```bash +npm run dev +``` + +This starts both the API server on **port 3000** and the SvelteKit UI on **port 5173** concurrently. Open [http://localhost:5173](http://localhost:5173). + +To run the API server alone: + +```bash +npm run server +``` + +--- + +## Environment Variables + +Create a `.env` file in the project root. None are required to run the app — it works with Yahoo Finance data out of the box. Optional keys unlock additional features. + +### `ANTHROPIC_API_KEY` — LLM news analysis *(optional)* + +Powers the **Analyze** button on each screener section. Without this key the button is disabled. + +1. Go to [console.anthropic.com](https://console.anthropic.com) +2. Create an API key under **API Keys** +3. Add to `.env`: + +```env +ANTHROPIC_API_KEY=sk-ant-... +``` + +### `SIMPLEFIN_SETUP_TOKEN` — Live bank/brokerage balances *(optional)* + +Powers the personal finance section of the Portfolio page (net worth, account balances, spending breakdown). + +1. Go to [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) and create a Setup Token +2. Add to `.env`: + +```env +SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly... +``` + +On first request the token is claimed automatically and the resulting Access URL is saved back to `.env` as `SIMPLEFIN_ACCESS_URL`. Subsequent restarts use the Access URL directly. + +### `API_KEY` — Bearer token auth *(optional)* + +When set, every API route requires `Authorization: Bearer `. Useful when the server is exposed to a network. `/health` and OPTIONS preflight are exempt. + +```env +API_KEY=your-secret-key +``` + +### `CLIENT_ORIGIN` — CORS allowed origin *(optional)* + +Defaults to `http://localhost:5173`. Change if the UI is served from a different origin. + +```env +CLIENT_ORIGIN=https://yourdomain.com +``` + +### Complete `.env` example + +```env +ANTHROPIC_API_KEY=sk-ant-... +SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly... +API_KEY=optional-secret +CLIENT_ORIGIN=http://localhost:5173 ``` --- ## Commands -| Command | What it does | +| Command | Description | |---|---| -| `npm run dev` | Start API server (port 3000) + SvelteKit UI (port 5173) together | +| `npm run dev` | Start API (port 3000) + UI (port 5173) together | | `npm run server` | Start API server only | -| `npm start` | CLI: fetch today's market news, extract tickers, screen them | -| `npm start -- watch` | CLI: screen the default watchlist | -| `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers | -| `npm run finance` | CLI: portfolio advice + SimpleFIN → `finance-report.html` | -| `npm run import-portfolio -- file.csv` | Import Robinhood/Vanguard/Fidelity CSV into `portfolio.json` | -| `npm test` | Run all 61 unit tests | -| `npm run test:watch` | Re-run tests on file changes | +| `npm run ui:install` | Install UI dependencies (first time / after `git pull`) | +| `npm test` | Run all unit + integration tests | +| `npm run test:watch` | Watch mode — re-run on file changes | +| `npm run typecheck` | TypeScript type check without emitting | | `npm run format` | Format all source files with Prettier | +| `npm run format:check` | Check formatting without writing (used in CI) | --- -## How the Screener Works - -Every asset is scored **twice** under different rule sets: - -### Market-Adjusted mode -Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market: - -| Gate | Formula | -|---|---| -| Stock P/E | S&P 500 P/E (via SPY) × 1.5× (or × 1.2× in HIGH rate regime) | -| Tech P/E | XLK sector P/E × 1.3× | -| REIT min yield | XLRE dividend yield × 0.85× | -| Bond min spread | LQD − TNX spread × 0.80× | - -### Fundamental mode -Strict Graham/value-investing gates — reflects genuine value regardless of market conditions: - -| Gate | Value | Rationale | -|---|---|---| -| Stock P/E | < 15× | Graham's actual rule (trailing earnings) | -| Stock PEG | < 1.0 | Lynch standard: PEG > 1.0 = paying full price | -| D/E ratio | < 1.5× | Distress typically starts above 2× | -| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress | -| Bond spread | > 1.5% | Must clear risk-free rate meaningfully | -| Bond duration | < 7 years | Rate sensitivity management | - -### Signals - -| Signal | Meaning | -|---|---| -| ✅ Strong Buy | Passes both lenses — genuinely good value | -| ⚡ Momentum | Passes market-adjusted, holds fundamentally | -| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection | -| 🔄 Neutral | Hold territory in one or both lenses | -| ❌ Avoid | Fails both | - ---- - -## Sector Overrides - -Sector-specific rules apply in both modes (not just inflated): - -| Sector | Key adjustments | -|---|---| -| **Technology** | P/E up to 35×, D/E up to 2.0 (buybacks), FCF weight raised | -| **REIT** | P/E/PEG disabled, scored on dividend yield + P/FFO proxy | -| **Financial** | D/E disabled, scored on ROE (≥12%) + P/B (< 1.5×) | -| **Energy** | FCF primary signal (weight 4), dividend yield scored | -| **Healthcare** | Revenue growth primary, P/E up to 25× (R&D burn) | -| **Communication** | FCF primary (META/GOOGL platforms), P/E up to 25× | -| **Consumer Staples** | Margin/ROE focus, low revenue growth expectations (2–5%) | -| **Consumer Discretionary** | Revenue growth primary, P/E up to 25× | - ---- - -## API Server - -``` -GET /health → { status: "ok" } -POST /api/screen → screen tickers - body: { tickers: string[] } -GET /api/screen/catalysts → Yahoo news → { tickers, stories } -GET /api/finance/portfolio → portfolio advice + net worth -GET /api/finance/market-context → live benchmark data -``` - -Set `CLIENT_ORIGIN` env var to allow a different CORS origin (default: `http://localhost:5173`). - ---- - -## Personal Finance - -Edit `portfolio.json` with your holdings (or import from a broker CSV): - -```json -{ - "holdings": [ - { "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" }, - { "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" }, - { "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" } - ] -} -``` - -`npm run finance` (CLI) or `GET /api/finance/portfolio` (API) screens your holdings and cross-references the screener signal with your gain/loss position to give hold/sell/add advice. - -### SimpleFIN (optional — live bank/brokerage balances) - -1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) -2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...` -3. On first run the Access URL is claimed and saved to `.env` automatically - -### Importing broker holdings +## Running Tests ```bash -npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv -npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv +npm test ``` -Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio.json`. +Uses Node's built-in `node:test` runner — no external framework. Tests cover: + +- Scoring rules and gate values (`ScoringConfig`, `RuleMerger`, `MarketRegime`) +- Asset scorers (`StockScorer`, `EtfScorer`, `BondScorer`) +- Data mapping (`DataMapper`) +- Portfolio advice logic (`PortfolioAdvisor`) +- LLM response parsing (`LLMAnalyst`) +- Repository CRUD (`MarketCallRepository`) +- Controller integration tests for all API routes (Fastify `inject()`, zero live network calls) + +Pre-commit hook runs Prettier then tests. Pre-push hook runs tests. --- @@ -145,81 +143,154 @@ Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio ``` bin/ - screen.js CLI screener entry point - finance.js CLI personal finance entry point - import-portfolio.js Broker CSV importer - server.js Fastify API server entry point + server.ts API server entry point -scripts/ - summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary) +server/ + app.ts Fastify app factory — wires DI, rate limiting, auth hook + controllers/ HTTP layer: parse request → call service → return response + services/ Business logic (ScreenerEngine, BenchmarkProvider, PortfolioAdvisor…) + repositories/ JSON file persistence (MarketCallRepository, PortfolioRepository) + clients/ External API connectors (YahooFinanceClient, SimpleFINClient, AnthropicClient) + models/ Domain entities: Stock, Etf, Bond + scorers/ Stateless scoring functions: StockScorer, EtfScorer, BondScorer + config/ ScoringConfig (all gates/weights), constants + types/ TypeScript interfaces, one file per domain -src/ - config/ - ScoringConfig.js All gates, weights, thresholds (single source of truth) - constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME +ui/ + src/ + routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys + lib/ + stores/ Svelte 5 reactive stores (screener.store, portfolio.store) + api/ Fetch wrappers for each API domain + portfolio/ Portfolio-specific components + calls/ Market calls components + styles/ Global SCSS design tokens and partials - market/ - YahooClient.js Wraps yahoo-finance2 v3 with retry + backoff - BenchmarkProvider.js Fetches live benchmarks → marketContext (1-hour cache) - MarketRegime.js Derives inflated gate overrides from live data + rate regime +tests/ Unit + integration tests - screener/ - ScreenerEngine.js Orchestrates fetch → score × 2. - screenTickers() → pure data (server/CLI) - screenWithProgress() → with stdout progress (CLI only) - DataMapper.js Normalises Yahoo payload → flat asset objects - Uses trailingPE (not forwardPE). Preserves negative FCF. - RuleMerger.js Merges base rules + sector overrides + MarketRegime - assets/ Stock, Etf, Bond data containers - scorers/ StockScorer, EtfScorer, BondScorer (stateless) - - analyst/ - CatalystAnalyst.js Extracts tickers from Yahoo Finance news headlines - - finance/ - clients/ - SimpleFINClient.js Auth + account fetching via Basic Auth header - PersonalFinanceAnalyzer.js Net worth, cash vs investments, spending - PortfolioAdvisor.js Hold/sell/add advice per holding - PortfolioImporter.js Parses Robinhood/Vanguard/Fidelity CSV - - reporters/ - HtmlReporter.js render() → string | generate() → file (CLI) - FinanceReporter.js render() → string | generate() → file (CLI) - - server/ - app.js Fastify app factory (buildApp) - routes/ - screener.js POST /api/screen, GET /api/screen/catalysts - finance.js GET /api/finance/portfolio, GET /api/finance/market-context +portfolio.json Your holdings (gitignored — create manually or via the UI) +market-calls.json Persisted market thesis calls (gitignored) +.benchmark-cache.json Benchmark data cache — survives server restart (gitignored) ``` --- -## Environment Variables +## User Guide -```bash -# .env -SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin -# or on first run: -SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly... +### Screener tab -# Optional server config -PORT=3000 -HOST=0.0.0.0 -CLIENT_ORIGIN=http://localhost:5173 # CORS allowed origin for SvelteKit UI -``` +The main view. On load it automatically fetches today's financial news, extracts the most-mentioned tickers, and screens them. + +#### Market context strip + +The row of chips at the top shows live benchmark data fetched from Yahoo Finance: + +| Chip | Meaning | +|---|---| +| 10Y | 10-year Treasury yield — the risk-free rate | +| VIX | Volatility index — market fear gauge | +| S&P | S&P 500 index price | +| S&P P/E | Trailing P/E of the S&P 500 (via SPY) | +| Tech P/E | Trailing P/E of the tech sector (via XLK) | +| REIT Yld | REIT dividend yield (via XLRE) | +| IG Sprd | Investment-grade bond spread above risk-free (LQD − TNX) | +| Rates | Rate regime: LOW / NORMAL / HIGH (based on 10Y yield) | +| Vol | Volatility regime: LOW / NORMAL / HIGH (based on VIX) | + +The rate regime affects how strict the Market-Adjusted gates are — in a HIGH rate environment the P/E multiplier compresses and bond spreads tighten. + +#### Signal Summary table + +Quick overview of all screened tickers with their signal, market-adjusted verdict, fundamental verdict, market cap tier, and growth style. + +#### Per-asset detail tables + +Expand each section (STOCK / ETF / BOND) for full metrics: P/E, PEG, ROE, margins, FCF yield, D/E, analyst consensus, DCF intrinsic value, 52-week movement, and more. + +#### Analyze button + +Runs an Anthropic LLM over the latest Yahoo Finance news for assets in that section. Returns a sentiment summary, affected industries, and related tickers to watch. Requires `ANTHROPIC_API_KEY`. + +#### Search tickers + +Click **Search tickers** to screen any custom list — type tickers comma or space separated and press Enter or click Screen. + +#### Signals explained + +| Signal | What it means | +|---|---| +| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates — genuine value at current prices | +| ⚡ Momentum | Passes Market-Adjusted, holds fundamentally — good in the current market but not a bargain | +| ⚠️ Speculation | Passes Market-Adjusted, fails Fundamental — priced for perfection, high risk | +| 🔄 Neutral | Borderline in one or both lenses — hold, no clear edge | +| ❌ Avoid | Fails both lenses | + +#### How scoring works + +Every asset is scored twice: + +**Market-Adjusted** gates move with the market. The stock P/E gate = SPY trailing P/E × 1.5 (compresses to × 1.2 in a HIGH rate regime). Tech P/E = XLK P/E × 1.3. This reflects what the market is currently willing to pay. + +**Fundamental** gates are fixed Graham/value-investing standards that never change: + +| Gate | Threshold | Rationale | +|---|---|---| +| Stock P/E | < 15× | Graham's actual rule | +| Stock PEG | < 1.0 | Lynch: PEG > 1.0 = paying full price | +| D/E ratio | < 1.5× | Distress typically starts above 2× | +| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress | + +Sector overrides apply in both modes — e.g. tech stocks allow P/E up to 35× and D/E up to 2.0, REITs are scored on yield rather than P/E. --- -## Testing +### Portfolio tab -61 unit tests, no external test framework: +Track your holdings and get hold/sell/add advice cross-referenced with screener signals. -```bash -npm test # summary output: "✅ 61 tests: 61 passed (0.02s)" -npm run test:watch # verbose spec output for development -``` +**Adding holdings** — click **+ Add Holding** and fill in ticker, shares, cost basis, asset type, and source broker. Holdings are saved to `portfolio.json` on disk. -Pre-commit: Prettier (auto-format staged files) + full test suite. -Pre-push: full test suite. +**Inline editing** — click the ✎ pencil icon on any row to edit shares, cost basis, type, or source directly in the table. + +**Advice column** — each holding is screened live and the signal is combined with your gain/loss position: + +| Situation | Advice | +|---|---| +| ✅ Strong Buy signal | Hold & Add | +| ⚡ Momentum + > 30% gain | Consider partial profit-taking | +| ⚠️ Speculation + > 20% gain | Reduce position | +| ❌ Avoid signal + in profit | Sell (Take Profits) | +| ❌ Avoid signal + at a loss | Sell (Cut Loss) | +| Crypto | Hold / Review position (no fundamental scoring) | + +**Personal finance section** *(requires SimpleFIN)* — when configured, the page also shows net worth, total assets vs liabilities, cash vs investments ratio, monthly income/spend, account balances, and a spending breakdown by category for the last 30 days. + +--- + +### Market Calls tab + +Record and track quarterly investment theses from the day you make the call. + +**Creating a call** — click **+ New Call** and fill in: +- **Title** — e.g. "Q3 2025 — Rate pivot & tech rotation" +- **Quarter** — the quarter this thesis applies to +- **Thesis** — the macro reasoning behind the call (min 10 characters) +- **Tickers** — the assets you're watching for this thesis + +When saved, the current price and signal for each ticker are snapshotted automatically. + +**Viewing performance** — click any call card to see the current price and signal for each ticker alongside the original snapshot, so you can measure how the thesis played out. + +**Calendar** — shows upcoming earnings dates, ex-dividend dates, and dividend payment dates for all tickers across your active calls. + +--- + +### Safe Buys tab + +A filtered view showing only tickers with a **✅ Strong Buy** signal across both lenses. A quick watchlist of assets passing the strictest criteria in the current market. + +--- + +### API rate limits + +`/api/screen`, `/api/screen/catalysts`, and `/api/analyze` are capped at **10 requests per minute** per IP. All other routes allow 60 per minute. diff --git a/package-lock.json b/package-lock.json index e019c6b..2e3517e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@fastify/cors": "^11.2.0", + "@fastify/rate-limit": "^10.2.1", "dotenv": "^16.0.0", "fastify": "^5.8.5", "yahoo-finance2": "^3.15.2" @@ -733,6 +734,27 @@ "node": ">= 10" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -783,6 +805,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", diff --git a/package.json b/package.json index f57bb31..d85fbb7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@fastify/cors": "^11.2.0", + "@fastify/rate-limit": "^10.2.1", "dotenv": "^16.0.0", "fastify": "^5.8.5", "yahoo-finance2": "^3.15.2" diff --git a/server/app.ts b/server/app.ts index 2bdce6e..ce5f1b5 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,5 +1,6 @@ -import Fastify from 'fastify'; +import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; import cors from '@fastify/cors'; +import rateLimit from '@fastify/rate-limit'; import { ScreenerController } from './controllers/screener.controller'; import { FinanceController } from './controllers/finance.controller'; import { CallsController } from './controllers/calls.controller'; @@ -31,6 +32,26 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) { 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 and OPTIONS preflight + if (req.url === '/health' || req.method === 'OPTIONS') return; + const header = req.headers['authorization'] ?? ''; + if (header !== `Bearer ${API_KEY}`) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + }); + } + const yahoo = new YahooFinanceClient(); const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger }); const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger }); diff --git a/server/controllers/analyze.controller.ts b/server/controllers/analyze.controller.ts index ce487a9..aa9e935 100644 --- a/server/controllers/analyze.controller.ts +++ b/server/controllers/analyze.controller.ts @@ -10,7 +10,11 @@ export class AnalyzeController { ) {} register(app: FastifyInstance): void { - app.post('/api/analyze', { schema: analyzeSchema }, this.analyze.bind(this)); + app.post( + '/api/analyze', + { schema: analyzeSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + this.analyze.bind(this), + ); } private async analyze(req: FastifyRequest, reply: FastifyReply) { diff --git a/server/controllers/screener.controller.ts b/server/controllers/screener.controller.ts index dae0863..55a5f4b 100644 --- a/server/controllers/screener.controller.ts +++ b/server/controllers/screener.controller.ts @@ -8,8 +8,16 @@ export class ScreenerController { constructor(private readonly engine: ScreenerEngine) {} register(app: FastifyInstance): void { - app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this)); - app.get('/api/screen/catalysts', this.catalysts.bind(this)); + app.post( + '/api/screen', + { schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + this.screen.bind(this), + ); + app.get( + '/api/screen/catalysts', + { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + this.catalysts.bind(this), + ); } private static serializeAssets(arr: LiveAssetResult[]) {