diff --git a/.env.example b/.env.example index 527ece8..7c591a3 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,11 @@ SIMPLEFIN_SETUP_TOKEN= # Remove SIMPLEFIN_SETUP_TOKEN once this appears. # # SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin + +# ── Docker / Production ─────────────────────────────────────────────────────── +# Bearer token for all API routes (optional — leave blank to disable) +API_KEY= + +# The public origin of your UI, used by Fastify for CORS +# Set to your domain when behind nginx (e.g. https://screener.example.com) +CLIENT_ORIGIN=http://localhost diff --git a/CLAUDE.md b/CLAUDE.md index 317852a..2796574 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1257,6 +1257,62 @@ lib/types/ **Timeline:** 4-6 weeks (after Phase 10). +--- + +### Phase 10.5 — Implementation Status (June 2026) + +#### ✅ Completed + +| Item | Details | +|------|---------| +| **Column sort** | Click any header to sort asc/desc; sort icon indicates active column | +| **Inline filter row** | Per-column `` filter row — no external sidebar needed for quick filters | +| **Verdict filter** | Dropdown in filter row with per-asset-type label sets (Strong Buy, Momentum, etc.) | +| **Style filter** | Dropdown to filter by growth style (High Growth, Turnaround, Value, etc.) | +| **Cap tier filter** | Dropdown to filter by market cap segment (Mega, Large, Mid, Small, Micro) | +| **Merged Signal + Verdict column** | Single `sv-pill` badge replaces two separate columns; color-coded by signal class | +| **Dot-scale score** | `●●●●○` 5-dot scale derived from raw score, with numeric beside it | +| **Flags hover badge** | `⚠ N` count badge; hover expands into tooltip showing individual risk flag pills | +| **Row lift highlight** | Brighter left border accent + lighter background on hover/open; sticky column background inherits row color (fixed stacking context clipping) | +| **Market strip rounding** | 10Y, VIX, REIT Yld → `.toFixed(1)`; IG Sprd → `.toFixed(2)`; P/E ratios → `fmtPE()` | +| **Regime badge colors** | `HIGH` = amber, `NORMAL` = muted gray, `LOW` = blue (driven by `data-regime` CSS attribute) | +| **Signal Summary hidden** | Removed from `+page.svelte` — table section no longer renders | + +#### 🔲 Next Up (Phase 10.5 Remaining) + +These five items are the immediate next build targets, in priority order: + +**1. Slide-in tearsheet panel** (`10.5d`) +- Replace the current inline expand row with a 420px right-side slide-in panel (CSS `transform: translateX` animation, 0.2s) +- Panel triggered by row click; closes via `[X]` button or `Escape` +- Sticky header shows ticker + price; body scrolls independently +- All current inline-expand content (display metrics grid) moves here as the first section + +**2. P/E + ROE + 52W columns in main table** (`10.5c`) +- Add three numeric columns: `P/E` (from `peRatio`), `ROE` (from `roe`), `52W Chg` (from `52W Chg` display metric) +- Right-aligned monospace; color-coded (P/E neutral, ROE green if >15%, 52W green/red by sign) +- Replace the existing free-form metric columns that show different fields per asset type + +**3. Valuation context (peer comparison) as first tearsheet section** (`10.5d §2`) +- Table inside tearsheet: `Metric | THIS | Sector | S&P500` +- Rows: P/E, PEG, ROE — pull sector avg and market avg from `marketContext.benchmarks` +- Makes the tearsheet immediately useful before any LLM analysis is run + +**4. Numeric range filters for P/E and ROE** (`10.5b`) +- Add two range inputs to the filter row (or a compact filter popover): `P/E max` and `ROE min` +- Filter applied client-side against `displayMetrics` values; integrates with existing `filteredRows()` chain +- Input type `number`, placeholder `P/E ≤` / `ROE ≥` + +**5. Threshold sensitivity block in tearsheet** (`10.5d §5`) +- Section inside tearsheet: "WHAT-IF SCENARIOS" +- Three computed rows: + - If P/E compresses to `currentPE * 0.75`: stock price impact % + - If growth slows to half current rate: stock price impact % (via DCF delta) + - If rates rise 100bps: discount rate impact on DCF intrinsic value +- All computed client-side from existing `dcfIntrinsicValue`, `peRatio`, `earningsGrowth` fields — no extra API call + +--- + ### 10.5a — UI Architecture: Three-Layer Layout ``` diff --git a/PHASES.md b/PHASES.md index 64c9f4e..de30f4a 100644 --- a/PHASES.md +++ b/PHASES.md @@ -882,3 +882,69 @@ A: Not yet. Only consider if: - You have $20K+ to spend on GPU infrastructure For now, optimize prompts instead. Good prompt beats fine-tuned model. + +--- + +## Future Enhancements (Unscheduled) + +### FE-1 — Pinned Stocks Watchlist + +**Concept:** User can pin any stock from the screener table. Pinned stocks appear in a persistent sidebar or dedicated panel showing: + +- Minimal summary: ticker, current price, signal badge, score +- Price-since-pin sparkline — a small inline chart showing how price moved from the day the stock was pinned to today +- Quick unpin button + +**Data requirements:** +- Store `{ ticker, pinnedAt, pinnedPrice }` in SQLite (`pinned_stocks` table) +- Fetch daily OHLC history from Yahoo Finance for the period `pinnedAt → now` to power the sparkline +- API: `GET /api/pins` (list), `POST /api/pins` (add), `DELETE /api/pins/:ticker` (remove), `GET /api/pins/:ticker/history` (OHLC since pin) + +**UI notes:** +- Pin button (📌) appears on hover of each summary row in the screener table +- Pinned panel can live in a collapsible drawer at the bottom, or a fixed right sidebar +- Sparkline: use a lightweight SVG path (no charting library needed); green if price above pin price, red if below +- On click of the sparkline, open a larger chart modal (Phase FE-1b — can use TradingView widget or Chart.js) + +**Why deferred:** Requires persistent per-user state (needs Phase 11 auth to be meaningful across sessions). Build after Phase 11. + +--- + +### FE-2 — Column Header Tooltips ("Why does this matter?") + +**Concept:** Clicking a column header in the screener summary row opens a small popover explaining: +- What the metric measures +- What a good vs bad value looks like +- How the screener uses it in scoring + +This turns the table into a learning tool — users understand *why* P/E or ROE matters, not just what the number is. + +**Suggested content per column:** + +| Header | What to explain | +|--------|----------------| +| **Score** | Weighted sum of all factor scores. >6 = quality, <4 = weak. Gates must pass first — score only fires if gates are cleared. | +| **Signal** | Compares two scoring lenses (Mkt-Adjusted vs Graham). Strong Buy = passes both. Momentum = passes inflated only. | +| **P/E** | Price-to-earnings. Lower = cheaper relative to earnings. Gate: <15x (Graham) or 30x warrants scrutiny unless high growth. | +| **PEG** | P/E ÷ growth rate. Normalises valuation for growth. <1.0 = paying less than growth justifies. Lynch's standard. | +| **ROE%** | Return on equity — how efficiently the company uses shareholder money. >15% is healthy; >30% is exceptional. Weighted 3× in scoring. | +| **OpMgn%** | Operating margin — profit per dollar of revenue before interest and tax. Measures business efficiency. | +| **FCF%** | Free cash flow yield — cash the business actually generates relative to price. Negative = cash-burning; gate fails. | +| **D/E** | Debt-to-equity. Measures leverage. Gate: <1.5× (general), <2.0× (tech). Higher than 2× raises distress risk. | +| **52W Chg** | Total price return over last 52 weeks. Positive momentum is healthy; >+50% may signal overextension. | +| **From High** | % below the 52-week high. -5% to -15% is a typical dip zone; ≤-30% triggers a risk flag. | +| **Analyst** | Yahoo consensus (1=Strong Buy, 5=Strong Sell). Requires ≥3 analysts to fire. ≤2.0 adds points; >4.0 subtracts. | +| **DCF Safety** | Margin of safety from a two-stage DCF model. Positive = stock appears undervalued vs intrinsic value. Only fires when FCF > 0. | +| **Cap** | Market cap tier: Mega (>$200B), Large ($10B+), Mid ($2B+), Small ($300M+), Micro (<$300M). Smaller = higher risk + volatility. | +| **Style** | Growth classification from revenue + earnings growth rate. High Growth = ≥15% revenue growth. Value = low growth + ≥3% yield. | + +**Implementation approach:** +- Add `data-tip` attribute to each `` in `AssetTable.svelte` +- On click, show a positioned `
` anchored to the header +- Dismiss on outside click or Escape +- No library needed — pure Svelte `$state` + CSS positioning +- Mobile: tip opens as a bottom sheet modal + +**Why not `title` attribute?** `title` tooltips are unstyled, non-interactive, and don't work on touch. A custom popover lets you format the content properly and include a "Good range" callout. + +**Why deferred:** Nice-to-have educational feature. Build after the core screener UI (Phase 10.5) is stable. diff --git a/nginx/market-screener.conf b/nginx/market-screener.conf new file mode 100644 index 0000000..64550e9 --- /dev/null +++ b/nginx/market-screener.conf @@ -0,0 +1,86 @@ +# market-screener.conf +# Drop this in /etc/nginx/sites-available/ and symlink to sites-enabled/ +# Replace YOUR_DOMAIN with your actual domain or server IP. + +upstream market_screener_ui { + server 127.0.0.1:3001; +} + +upstream market_screener_api { + server 127.0.0.1:3000; +} + +server { + listen 80; + server_name YOUR_DOMAIN; + + # Redirect HTTP → HTTPS (uncomment once you have a cert) + # return 301 https://$host$request_uri; + + # --- API routes --- + location /api/ { + proxy_pass http://market_screener_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + location /health { + proxy_pass http://market_screener_api; + } + + # Polygon / other webhook paths hitting /webhooks/* + location /webhooks/ { + proxy_pass http://market_screener_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } + + # --- SvelteKit UI (everything else) --- + location / { + proxy_pass http://market_screener_ui; + proxy_http_version 1.1; + # Required for SvelteKit HMR in dev; harmless in prod + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } +} + +# --- HTTPS block (uncomment + fill in after running certbot) --- +# server { +# listen 443 ssl http2; +# server_name YOUR_DOMAIN; +# +# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers HIGH:!aNULL:!MD5; +# +# location /api/ { +# proxy_pass http://market_screener_api; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } +# +# location / { +# proxy_pass http://market_screener_ui; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } +# } diff --git a/server/app.ts b/server/app.ts index a26b806..577c3c9 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 = { 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// 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' })); diff --git a/server/domains/auth/AuthController.ts b/server/domains/auth/AuthController.ts new file mode 100644 index 0000000..79767d5 --- /dev/null +++ b/server/domains/auth/AuthController.ts @@ -0,0 +1,146 @@ +/** + * AuthController — HTTP layer for authentication. + * + * POST /auth/register — create account (requires invite code generated at boot) + * POST /auth/login — verify credentials, returns JWT + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import type { AuthService } from './AuthService.js'; + +interface RegisterBody { + email: string; + password: string; + inviteCode: string; + role?: 'trader' | 'viewer'; +} + +interface LoginBody { + email: string; + password: string; +} + +interface ForgotBody { + email: string; +} + +interface ResetBody { + token: string; + password: string; +} + +const registerSchema = { + body: { + type: 'object', + required: ['email', 'password', 'inviteCode'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + inviteCode: { type: 'string' }, + role: { type: 'string', enum: ['trader', 'viewer'] }, + }, + }, +}; + +const loginSchema = { + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string' }, + }, + }, +}; + +const forgotSchema = { + body: { + type: 'object', + required: ['email'], + properties: { + email: { type: 'string', format: 'email' }, + }, + }, +}; + +const resetSchema = { + body: { + type: 'object', + required: ['token', 'password'], + properties: { + token: { type: 'string', minLength: 32 }, + password: { type: 'string', minLength: 8 }, + }, + }, +}; + +export class AuthController { + readonly #inviteCode: string; + + constructor( + private readonly authService: AuthService, + inviteCode: string, + ) { + this.#inviteCode = inviteCode; + } + + register(app: FastifyInstance): void { + app.post('/auth/register', { schema: registerSchema }, this.#register.bind(this)); + app.post('/auth/login', { schema: loginSchema }, this.#login.bind(this)); + app.post('/auth/forgot-password', { schema: forgotSchema }, this.#forgot.bind(this)); + app.post('/auth/reset-password', { schema: resetSchema }, this.#reset.bind(this)); + } + + async #register(req: FastifyRequest, reply: FastifyReply): Promise { + const { email, password, inviteCode, role } = req.body as RegisterBody; + + if (inviteCode !== this.#inviteCode) { + return reply.code(403).send({ error: 'Invalid invite code' }); + } + + try { + const result = this.authService.register(email, password, role ?? 'viewer'); + reply.code(201).send(result); + } catch (err: unknown) { + const e = err as { message: string; statusCode?: number }; + reply.code(e.statusCode ?? 500).send({ error: e.message }); + } + } + + async #login(req: FastifyRequest, reply: FastifyReply): Promise { + const { email, password } = req.body as LoginBody; + try { + const result = this.authService.login(email, password); + reply.send(result); + } catch (err: unknown) { + const e = err as { message: string; statusCode?: number }; + reply.code(e.statusCode ?? 500).send({ error: e.message }); + } + } + + async #forgot(req: FastifyRequest, reply: FastifyReply): Promise { + const { email } = req.body as ForgotBody; + const origin = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173'; + try { + this.authService.forgotPassword(email, origin); + } catch (err) { + // Log server-side but never expose details to client + console.error('[forgot-password] error:', err); + } + // Always return 200 — never reveal whether the email exists or any error occurred + reply.send({ + message: 'If that email is registered, a reset link has been printed to the server console.', + }); + } + + async #reset(req: FastifyRequest, reply: FastifyReply): Promise { + const { token, password } = req.body as ResetBody; + try { + this.authService.resetPassword(token, password); + reply.send({ message: 'Password updated. You can now log in.' }); + } catch (err: unknown) { + const e = err as { message: string; statusCode?: number }; + reply.code(e.statusCode ?? 500).send({ error: e.message }); + } + } +} diff --git a/server/domains/auth/AuthService.ts b/server/domains/auth/AuthService.ts new file mode 100644 index 0000000..4774ca4 --- /dev/null +++ b/server/domains/auth/AuthService.ts @@ -0,0 +1,146 @@ +/** + * AuthService — authentication logic. + * + * JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto. + * Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended). + */ + +import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto'; +import type { UserStore } from './UserStore.js'; +import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js'; + +// ── JWT helpers ─────────────────────────────────────────────────────────────── + +function b64url(input: string | Buffer): string { + const buf = typeof input === 'string' ? Buffer.from(input) : input; + return buf.toString('base64url'); +} + +function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string { + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const now = Math.floor(Date.now() / 1000); + const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec })); + const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); + return `${header}.${body}.${sig}`; +} + +export function verifyJwt(token: string, secret: string): TokenPayload { + const parts = token.split('.'); + if (parts.length !== 3) throw new Error('Invalid token format'); + const [header, body, sig] = parts; + const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); + if (sig !== expected) throw new Error('Invalid token signature'); + const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString()); + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired'); + return payload; +} + +// ── Password helpers ────────────────────────────────────────────────────────── + +const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 }; + +function hashPassword(plain: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, { + N: SCRYPT_PARAMS.N, + r: SCRYPT_PARAMS.r, + p: SCRYPT_PARAMS.p, + }).toString('hex'); + return `${salt}:${hash}`; +} + +function verifyPassword(plain: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, { + N: SCRYPT_PARAMS.N, + r: SCRYPT_PARAMS.r, + p: SCRYPT_PARAMS.p, + }); + return timingSafeEqual(Buffer.from(hash, 'hex'), attempt); +} + +// ── AuthService ─────────────────────────────────────────────────────────────── + +export class AuthService { + readonly #store: UserStore; + readonly #secret: string; + + constructor(store: UserStore, secret: string) { + this.#store = store; + this.#secret = secret; + } + + register(email: string, password: string, role: Role = 'viewer'): AuthResponse { + const existing = this.#store.findByEmail(email); + if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 }); + + const passwordHash = hashPassword(password); + const user = this.#store.create(email, passwordHash, role); + const token = this.#issueToken(user); + return { token, user }; + } + + login(email: string, password: string): AuthResponse { + const row = this.#store.findByEmail(email); + if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 }); + + const valid = verifyPassword(password, row.password_hash); + if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 }); + + this.#store.touchLogin(row.id); + + const user: User = { + id: row.id, + email: row.email, + role: row.role, + createdAt: row.created_at, + lastLogin: row.last_login, + }; + const token = this.#issueToken(user); + return { token, user }; + } + + verify(token: string): TokenPayload { + return verifyJwt(token, this.#secret); + } + + /** + * Generate a password reset token and print the reset link to the console. + * Always returns success (no email enumeration). + */ + forgotPassword(email: string, appOrigin: string): void { + this.#store.purgeExpiredTokens(); + const user = this.#store.findByEmail(email); + if (!user) return; // silent — don't reveal whether email exists + + const token = randomUUID().replace(/-/g, ''); // 32-char hex + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour + this.#store.createResetToken(user.id, token, expiresAt); + + const link = `${appOrigin}/auth/reset-password?token=${token}`; + console.log('\n🔐 Password reset requested for:', email); + console.log(' Link (expires in 1 hour):'); + console.log(` ${link}\n`); + } + + /** + * Validate a reset token and update the user's password. + */ + resetPassword(token: string, newPassword: string): void { + const row = this.#store.findResetToken(token); + if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 }); + if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 }); + if (new Date(row.expires_at) < new Date()) { + throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 }); + } + + const passwordHash = hashPassword(newPassword); + this.#store.updatePassword(row.user_id, passwordHash); + this.#store.markTokenUsed(token); + } + + #issueToken(user: User): string { + return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret); + } +} diff --git a/server/domains/auth/UserStore.ts b/server/domains/auth/UserStore.ts new file mode 100644 index 0000000..2df45d7 --- /dev/null +++ b/server/domains/auth/UserStore.ts @@ -0,0 +1,68 @@ +/** + * UserStore — persistence layer for the users table. + * All queries go through DatabaseConnection for audit + safety. + */ + +import { randomUUID } from 'crypto'; +import type { DatabaseConnection } from '../shared/db/DatabaseConnection.js'; +import { USER_QUERIES, RESET_TOKEN_QUERIES } from '../shared/db/queries.constant.js'; +import type { Role, User, UserRow } from './auth.model.js'; + +export class UserStore { + constructor(private readonly db: DatabaseConnection) {} + + findByEmail(email: string): UserRow | undefined { + return this.db.rawGet(USER_QUERIES.SELECT_BY_EMAIL, [email]); + } + + findById(id: string): User | undefined { + const row = this.db.rawGet(USER_QUERIES.SELECT_BY_ID, [id]); + if (!row) return undefined; + return this.#toUser(row); + } + + create(email: string, passwordHash: string, role: Role = 'viewer'): User { + const id = randomUUID(); + const createdAt = new Date().toISOString(); + this.db.rawRun(USER_QUERIES.INSERT, [id, email, passwordHash, role, createdAt]); + return { id, email, role, createdAt, lastLogin: null }; + } + + touchLogin(id: string): void { + this.db.rawRun(USER_QUERIES.UPDATE_LAST_LOGIN, [new Date().toISOString(), id]); + } + + updatePassword(id: string, passwordHash: string): void { + this.db.rawRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]); + } + + // ── Password reset tokens ────────────────────────────────────────────────── + + createResetToken(userId: string, token: string, expiresAt: string): void { + this.db.rawRun(RESET_TOKEN_QUERIES.INSERT, [token, userId, expiresAt]); + } + + findResetToken( + token: string, + ): { token: string; user_id: string; expires_at: string; used: number } | undefined { + return this.db.rawGet(RESET_TOKEN_QUERIES.FIND, [token]); + } + + markTokenUsed(token: string): void { + this.db.rawRun(RESET_TOKEN_QUERIES.MARK_USED, [token]); + } + + purgeExpiredTokens(): void { + this.db.rawRun(RESET_TOKEN_QUERIES.PURGE, [new Date().toISOString()]); + } + + #toUser(row: UserRow): User { + return { + id: row.id, + email: row.email, + role: row.role, + createdAt: row.created_at, + lastLogin: row.last_login, + }; + } +} diff --git a/server/domains/auth/auth.model.ts b/server/domains/auth/auth.model.ts new file mode 100644 index 0000000..f8b2afb --- /dev/null +++ b/server/domains/auth/auth.model.ts @@ -0,0 +1,36 @@ +// ── Auth domain types ───────────────────────────────────────────────────────── + +export type Role = 'trader' | 'viewer' | 'admin'; + +export interface User { + id: string; + email: string; + role: Role; + createdAt: string; + lastLogin: string | null; +} + +/** Full user row including password hash — only used internally by UserStore/AuthService. */ +export interface UserRow { + id: string; + email: string; + password_hash: string; + role: Role; + created_at: string; + last_login: string | null; +} + +/** Payload embedded in the JWT. */ +export interface TokenPayload { + sub: string; // user id + email: string; + role: Role; + iat?: number; + exp?: number; +} + +/** Response body for successful login / register. */ +export interface AuthResponse { + token: string; + user: User; +} diff --git a/server/domains/auth/index.ts b/server/domains/auth/index.ts new file mode 100644 index 0000000..c004b61 --- /dev/null +++ b/server/domains/auth/index.ts @@ -0,0 +1,4 @@ +export { AuthController } from './AuthController.js'; +export { AuthService, verifyJwt } from './AuthService.js'; +export { UserStore } from './UserStore.js'; +export type { User, UserRow, Role, TokenPayload, AuthResponse } from './auth.model.js'; diff --git a/server/domains/calls/calls.controller.ts b/server/domains/calls/calls.controller.ts index 2f94964..c89fc26 100644 --- a/server/domains/calls/calls.controller.ts +++ b/server/domains/calls/calls.controller.ts @@ -1,16 +1,27 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify'; import { MarketCallRepository } from '../../domains/shared'; import { CalendarService } from './CalendarService'; import { ScreenerEngine } from '../screener'; import type { SnapshotEntry } from '../../domains/shared'; import { callSchema } from '../../domains/shared/types/schemas'; +interface CallsControllerOptions { + authGuard?: preHandlerHookHandler; + traderGuard?: preHandlerHookHandler; +} + export class CallsController { + readonly #guards: preHandlerHookHandler[]; + constructor( private readonly repo: MarketCallRepository, private readonly engine: ScreenerEngine, private readonly calendar: CalendarService, - ) {} + options: CallsControllerOptions = {}, + ) { + this.#guards = + options.authGuard && options.traderGuard ? [options.authGuard, options.traderGuard] : []; + } private static toSnapshot(r: any): SnapshotEntry | null { if (!r) return null; @@ -30,8 +41,12 @@ export class CallsController { app.get('/api/calls', this.list.bind(this)); app.get('/api/calls/calendar', this.handleCalendar.bind(this)); app.get('/api/calls/:id', this.get.bind(this)); - app.post('/api/calls', { schema: callSchema }, this.create.bind(this)); - app.delete('/api/calls/:id', this.remove.bind(this)); + app.post( + '/api/calls', + { schema: callSchema, preHandler: this.#guards }, + this.create.bind(this), + ); + app.delete('/api/calls/:id', { preHandler: this.#guards }, this.remove.bind(this)); } private async list() { diff --git a/server/domains/finance/finance.controller.ts b/server/domains/finance/finance.controller.ts index 8c716ca..631e5dc 100644 --- a/server/domains/finance/finance.controller.ts +++ b/server/domains/finance/finance.controller.ts @@ -1,26 +1,59 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared'; -import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener'; -import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor'; -import type { PortfolioHolding } from '../../domains/shared'; -import { holdingSchema } from '../../domains/shared/types/schemas'; +import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify'; +import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared/index.js'; +import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener/index.js'; +import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor.js'; +import type { PortfolioHolding } from '../../domains/shared/index.js'; +import { holdingSchema } from '../../domains/shared/types/schemas.js'; +import type { TokenPayload } from '../auth/index.js'; + +interface FinanceControllerOptions { + authGuard?: preHandlerHookHandler; + traderGuard?: preHandlerHookHandler; +} + +type AuthRequest = FastifyRequest & { user?: TokenPayload }; + +function userId(req: FastifyRequest): string { + return (req as AuthRequest).user?.sub ?? ''; +} export class FinanceController { + // All portfolio routes only need a valid login — data is already user-scoped by user_id. + // No role restriction needed; any registered user can manage their own portfolio. + readonly #authGuards: preHandlerHookHandler[]; + constructor( private readonly engine: ScreenerEngine, private readonly repo: PortfolioRepository, private readonly advisor: PortfolioAdvisor, - ) {} + options: FinanceControllerOptions = {}, + ) { + this.#authGuards = options.authGuard ? [options.authGuard] : []; + } register(app: FastifyInstance): void { - app.get('/api/finance/portfolio', this.portfolio.bind(this)); - app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this)); - app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this)); + app.get('/api/finance/portfolio', { preHandler: this.#authGuards }, this.portfolio.bind(this)); + app.post( + '/api/finance/holdings', + { + schema: holdingSchema, + preHandler: this.#authGuards, + }, + this.addHolding.bind(this), + ); + app.delete( + '/api/finance/holdings/:ticker', + { + preHandler: this.#authGuards, + }, + this.removeHolding.bind(this), + ); app.get('/api/finance/market-context', this.marketContext.bind(this)); } - private async portfolio(_req: FastifyRequest, _reply: FastifyReply) { - const { holdings } = this.repo.exists() ? this.repo.read() : { holdings: [] }; + private async portfolio(req: FastifyRequest, _reply: FastifyReply) { + const uid = userId(req); + const { holdings } = this.repo.exists(uid) ? this.repo.read(uid) : { holdings: [] }; let personalFinance = null; if (process.env.SIMPLEFIN_ACCESS_URL) { @@ -43,6 +76,7 @@ export class FinanceController { } private async addHolding(req: FastifyRequest, reply: FastifyReply) { + const uid = userId(req); const { ticker, shares, @@ -50,14 +84,14 @@ export class FinanceController { type = 'stock', source = 'Manual', } = req.body as PortfolioHolding; - const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }); + const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }, uid); return reply.code(201).send(entry); } private async removeHolding(req: FastifyRequest, reply: FastifyReply) { + const uid = userId(req); const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); - - const removed = this.repo.remove(ticker); + const removed = this.repo.remove(ticker, uid); if (!removed) return reply.code(404).send({ error: 'Holding not found' }); return { ok: true }; } diff --git a/server/domains/shared/db/DatabaseConnection.ts b/server/domains/shared/db/DatabaseConnection.ts index 495e75f..f208dbd 100644 --- a/server/domains/shared/db/DatabaseConnection.ts +++ b/server/domains/shared/db/DatabaseConnection.ts @@ -139,6 +139,24 @@ export class DatabaseConnection { return txn(); } + /** + * Execute a raw SQL SELECT and return the first row. + * Use only when QueryBuilder is not practical (e.g. auth domain with static queries). + */ + rawGet>(sql: string, params: unknown[] = []): T | undefined { + const stmt = this.getOrCacheStatement(sql); + return stmt.get(...params) as T | undefined; + } + + /** + * Execute a raw SQL INSERT/UPDATE/DELETE. + * Use only when QueryBuilder is not practical (e.g. auth domain with static queries). + */ + rawRun(sql: string, params: unknown[] = []): number { + const stmt = this.getOrCacheStatement(sql); + return stmt.run(...params).changes; + } + /** * Get the raw better-sqlite3 Db instance (for advanced use only). * Prefer the DatabaseConnection methods. diff --git a/server/domains/shared/db/DatabaseInitializer.ts b/server/domains/shared/db/DatabaseInitializer.ts index 2b5c387..2b26394 100644 --- a/server/domains/shared/db/DatabaseInitializer.ts +++ b/server/domains/shared/db/DatabaseInitializer.ts @@ -4,14 +4,15 @@ * Handles: * - Creating/opening SQLite database * - Running DDL schema setup + * - Runtime ALTER TABLE migrations (safe to re-run) + * - Seeding the admin user from ADMIN_EMAIL + ADMIN_PASSWORD env vars * - Migrating legacy JSON files (one-time) */ import BetterSqlite3 from 'better-sqlite3'; import { existsSync, readFileSync, renameSync } from 'fs'; -import { randomUUID } from 'crypto'; -import { DDL } from './queries.constant'; -import { QueryBuilder } from '../utils/QueryBuilder'; +import { randomUUID, randomBytes, scryptSync } from 'crypto'; +import { DDL, RUNTIME_MIGRATIONS, HOLDINGS_QUERIES, USER_QUERIES } from './queries.constant.js'; export type Db = BetterSqlite3.Database; @@ -43,85 +44,137 @@ interface LegacyCall { * * Steps: * 1. Create/open database file - * 2. Enable WAL mode (concurrent read safety) - * 3. Enable foreign keys - * 4. Run DDL (create tables if missing) - * 5. Migrate legacy JSON files (one-time) - * - * @param path Path to database file (default: ./market-screener.db) - * @returns Opened database instance (wrap in DatabaseConnection for safe access) + * 2. Enable WAL mode + foreign keys + * 3. Run DDL (create tables if missing) + * 4. Run runtime ALTER TABLE migrations (adds user_id etc. to existing DBs) + * 5. Seed admin user from env vars + * 6. Migrate legacy JSON files (one-time) */ export function createDb(path = './market-screener.db'): Db { const db = new BetterSqlite3(path); db.pragma('journal_mode = WAL'); - db.pragma('foreign_keys = ON'); + db.pragma('foreign_keys = OFF'); // off during schema changes, back on after db.exec(DDL); + runRuntimeMigrations(db); + db.pragma('foreign_keys = ON'); + seedAdmin(db); + // Upgrade any legacy 'viewer' accounts to 'trader' so all users have full access + db.prepare("UPDATE users SET role = 'trader' WHERE role = 'viewer'").run(); migrateJson(db); return db; } -// ── Migration Helpers ──────────────────────────────────────────────────────── +// ── Runtime migrations ─────────────────────────────────────────────────────── /** - * Migrate legacy JSON files to SQLite (one-time, non-fatal). - * Called automatically during database initialization. + * Run ALTER TABLE statements that bring existing DBs up to the current schema. + * Each statement is wrapped in try/catch — SQLite throws if column already exists. */ +function runRuntimeMigrations(db: Db): void { + for (const sql of RUNTIME_MIGRATIONS) { + try { + db.exec(sql); + } catch { + // Column already exists — safe to ignore + } + } +} + +// ── Admin seeding ──────────────────────────────────────────────────────────── + +/** + * Create the admin account on first boot if ADMIN_EMAIL + ADMIN_PASSWORD are set. + * No-ops if the admin already exists. + */ +function seedAdmin(db: Db): void { + const email = process.env.ADMIN_EMAIL; + const password = process.env.ADMIN_PASSWORD; + if (!email || !password) return; + + const existing = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(email); + if (existing) { + // Migrate any ownerless holdings from before auth was added to this admin + const adminRow = existing as { id: string }; + db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(adminRow.id); + return; + } + + // Hash password using the same scrypt approach as AuthService + // (inline here to avoid circular imports with the auth domain) + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex'); + const passwordHash = `${salt}:${hash}`; + + const id = randomUUID(); + const createdAt = new Date().toISOString(); + db.prepare(USER_QUERIES.INSERT).run(id, email, passwordHash, 'admin', createdAt); + + // Migrate any ownerless holdings to this new admin + db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(id); +} + +// ── JSON migration helpers ─────────────────────────────────────────────────── + function migrateJson(db: Db): void { migratePortfolio(db); migrateCalls(db); } -/** - * Migrate portfolio.json → holdings table. - * If portfolio.json exists, import all holdings and rename to portfolio.json.migrated. - * If import fails, leave portfolio.json in place (non-fatal). - */ function migratePortfolio(db: Db): void { const src = './portfolio.json'; if (!existsSync(src)) return; + // Need admin id to assign migrated holdings + const adminEmail = process.env.ADMIN_EMAIL; + if (!adminEmail) return; + const adminRow = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(adminEmail) as + | { id: string } + | undefined; + if (!adminRow) return; + try { const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as { holdings: LegacyHolding[]; }; const insertAll = db.transaction((rows: LegacyHolding[]) => { + const stmt = db.prepare(` + INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id) + VALUES (?, ?, ?, ?, ?, ?) + `); for (const h of rows) { - const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [ + stmt.run( h.ticker.toUpperCase(), h.shares, h.costBasis ?? 0, h.type ?? 'stock', h.source ?? 'Manual', - ]); - db.prepare(qb.sql).run(...qb.queryParams); + adminRow.id, + ); } }); insertAll(holdings); renameSync(src, `${src}.migrated`); } catch { - // Non-fatal: leave portfolio.json in place if migration fails + // Non-fatal } } -/** - * Migrate market-calls.json → market_calls table. - * If market-calls.json exists, import all calls and rename to market-calls.json.migrated. - * If import fails, leave market-calls.json in place (non-fatal). - */ function migrateCalls(db: Db): void { const src = './market-calls.json'; if (!existsSync(src)) return; try { - const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { - calls: LegacyCall[]; - }; + const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: LegacyCall[] }; const insertAll = db.transaction((rows: LegacyCall[]) => { + const stmt = db.prepare(` + INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); for (const c of rows) { - const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [ + stmt.run( c.id ?? randomUUID(), c.title, c.quarter, @@ -130,14 +183,13 @@ function migrateCalls(db: Db): void { JSON.stringify(c.tickers ?? []), JSON.stringify(c.snapshot ?? {}), c.createdAt, - ]); - db.prepare(qb.sql).run(...qb.queryParams); + ); } }); insertAll(calls); renameSync(src, `${src}.migrated`); } catch { - // Non-fatal: leave market-calls.json in place if migration fails + // Non-fatal } } diff --git a/server/domains/shared/db/queries.constant.ts b/server/domains/shared/db/queries.constant.ts index 4772722..7fb9fb1 100644 --- a/server/domains/shared/db/queries.constant.ts +++ b/server/domains/shared/db/queries.constant.ts @@ -2,8 +2,7 @@ * SQL Query Constants * * All SQL queries used in the application. - * Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL). - * QueryBuilder looks them up and binds parameters. + * Repositories reference these by name. * * All queries use parameterized statements (?) for security. * User input NEVER goes into the SQL string. @@ -12,25 +11,33 @@ // ── Holdings Table Queries ─────────────────────────────────────────────────── export const HOLDINGS_QUERIES = { - // Check if any holdings exist - EXISTS: 'SELECT COUNT(*) AS n FROM holdings', + // Check if any holdings exist for a user + EXISTS: 'SELECT COUNT(*) AS n FROM holdings WHERE user_id = ?', - // Get all holdings, sorted by ticker - SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC', + // Get all holdings for a user, sorted by ticker + SELECT_ALL: ` + SELECT ticker, shares, cost_basis, type, source + FROM holdings + WHERE user_id = ? + ORDER BY ticker ASC + `, - // Insert or update a holding (UPSERT) + // Insert or update a holding scoped to a user UPSERT: ` - INSERT INTO holdings (ticker, shares, cost_basis, type, source) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(ticker) DO UPDATE SET + INSERT INTO holdings (ticker, shares, cost_basis, type, source, user_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(ticker, user_id) DO UPDATE SET shares = excluded.shares, cost_basis = excluded.cost_basis, type = excluded.type, source = excluded.source `, - // Delete a holding by ticker - DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?', + // Delete a holding by ticker for a specific user + DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ? AND user_id = ?', + + // Migrate ownerless holdings to admin user (one-time) + MIGRATE_TO_ADMIN: "UPDATE holdings SET user_id = ? WHERE user_id IS NULL OR user_id = ''", }; // ── Market Calls Table Queries ─────────────────────────────────────────────── @@ -65,8 +72,8 @@ export const MARKET_CALLS_QUERIES = { export const MIGRATION_QUERIES = { // Insert holdings during migration HOLDINGS_INSERT_OR_IGNORE: ` - INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source) - VALUES (?, ?, ?, ?, ?) + INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id) + VALUES (?, ?, ?, ?, ?, ?) `, // Insert market calls during migration @@ -76,15 +83,78 @@ export const MIGRATION_QUERIES = { `, }; +// ── User Table Queries ─────────────────────────────────────────────────────── + +export const USER_QUERIES = { + SELECT_BY_EMAIL: ` + SELECT id, email, password_hash, role, created_at, last_login + FROM users WHERE email = ? + `, + + SELECT_BY_ID: ` + SELECT id, email, role, created_at, last_login + FROM users WHERE id = ? + `, + + INSERT: ` + INSERT INTO users (id, email, password_hash, role, created_at) + VALUES (?, ?, ?, ?, ?) + `, + + UPDATE_LAST_LOGIN: ` + UPDATE users SET last_login = ? WHERE id = ? + `, +}; + +// ── Password Reset Token Queries ───────────────────────────────────────────── + +export const RESET_TOKEN_QUERIES = { + INSERT: ` + INSERT INTO password_reset_tokens (token, user_id, expires_at) + VALUES (?, ?, ?) + `, + FIND: ` + SELECT token, user_id, expires_at, used + FROM password_reset_tokens + WHERE token = ? + `, + MARK_USED: ` + UPDATE password_reset_tokens SET used = 1 WHERE token = ? + `, + // Clean up expired/used tokens older than 24h + PURGE: ` + DELETE FROM password_reset_tokens + WHERE used = 1 OR expires_at < ? + `, +}; + // ── Schema Definition (DDL) ────────────────────────────────────────────────── export const DDL = ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')), + created_at TEXT NOT NULL, + last_login TEXT + ); + CREATE TABLE IF NOT EXISTS holdings ( - ticker TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id), shares REAL NOT NULL, cost_basis REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'stock', - source TEXT NOT NULL DEFAULT 'Manual' + source TEXT NOT NULL DEFAULT 'Manual', + PRIMARY KEY (ticker, user_id) + ); + + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS market_calls ( @@ -98,3 +168,11 @@ export const DDL = ` created_at TEXT NOT NULL ); `; + +// ── Runtime migrations (ALTER TABLE for existing DBs) ──────────────────────── +// These are safe to run repeatedly — they no-op if the column already exists. + +export const RUNTIME_MIGRATIONS = [ + // Add user_id to holdings if upgrading from pre-auth schema + `ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL DEFAULT '' REFERENCES users(id)`, +]; diff --git a/server/domains/shared/persistence/PortfolioRepository.ts b/server/domains/shared/persistence/PortfolioRepository.ts index fdd4bf2..d952274 100644 --- a/server/domains/shared/persistence/PortfolioRepository.ts +++ b/server/domains/shared/persistence/PortfolioRepository.ts @@ -1,34 +1,33 @@ -import { DatabaseConnection } from '../db/index'; -import { QueryBuilder } from '../utils/QueryBuilder'; -import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer'; -import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types'; +import { DatabaseConnection } from '../db/index.js'; +import { QueryBuilder } from '../utils/QueryBuilder.js'; +import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer.js'; +import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types/index.js'; export class PortfolioRepository { constructor(private readonly db: DatabaseConnection) {} /** - * Check if portfolio has any holdings. + * Check if a user has any holdings. */ - exists(): boolean { - const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS'); + exists(userId: string): boolean { + const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS', [userId]); const row = this.db.get<{ n: number }>(qb); return row ? row.n > 0 : false; } /** - * Read all holdings. + * Read all holdings for a user. */ - read(): PortfolioData { - const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL'); + read(userId: string): PortfolioData { + const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL', [userId]); const rows = this.db.all(qb); return { holdings: rows.map(PortfolioRepository.toHolding) }; } /** - * Insert or update a holding (UPSERT). + * Insert or update a holding scoped to a user (UPSERT). */ - upsert(entry: PortfolioHolding): PortfolioHolding { - // Sanitize inputs + upsert(entry: PortfolioHolding, userId: string): PortfolioHolding { const ticker = sanitizeTicker(entry.ticker); const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 }); const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 }); @@ -41,6 +40,7 @@ export class PortfolioRepository { costBasis, type, source, + userId, ]); this.db.run(qb); @@ -48,20 +48,15 @@ export class PortfolioRepository { } /** - * Delete a holding by ticker. + * Delete a holding by ticker for a specific user. */ - remove(ticker: string): boolean { - // Sanitize input + remove(ticker: string, userId: string): boolean { const sanitizedTicker = sanitizeTicker(ticker); - const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker]); - + const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker, userId]); const changes = this.db.run(qb); return changes > 0; } - /** - * Convert database row to domain object. - */ private static toHolding(row: HoldingRow): PortfolioHolding { return { ticker: row.ticker, diff --git a/ui/src/lib/api/auth.ts b/ui/src/lib/api/auth.ts new file mode 100644 index 0000000..8101ccf --- /dev/null +++ b/ui/src/lib/api/auth.ts @@ -0,0 +1,47 @@ +import type { AuthResponse } from '$lib/types.js'; +import { authStore } from '$lib/stores/auth.store.svelte.js'; + +const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; + +/** + * fetch() wrapper that automatically attaches the JWT Bearer token. + * Use this for all API calls that require authentication. + */ +export function authFetch(url: string, init: RequestInit = {}): Promise { + const token = authStore.token; + const headers = new Headers(init.headers); + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + if (token) headers.set('Authorization', `Bearer ${token}`); + return fetch(url, { ...init, headers }); +} + +export async function login(email: string, password: string): Promise { + const res = await fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({ error: 'Login failed' })); + throw new Error(error ?? 'Login failed'); + } + return res.json() as Promise; +} + +export async function register( + email: string, + password: string, + role: 'trader' | 'viewer' = 'viewer', + inviteCode = '', +): Promise { + const res = await fetch(`${BASE}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, role, inviteCode }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({ error: 'Registration failed' })); + throw new Error(error ?? 'Registration failed'); + } + return res.json() as Promise; +} diff --git a/ui/src/lib/api/calls.ts b/ui/src/lib/api/calls.ts index 7774dec..f84f8ee 100644 --- a/ui/src/lib/api/calls.ts +++ b/ui/src/lib/api/calls.ts @@ -1,4 +1,5 @@ import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js'; +import { authFetch } from './auth.js'; const BASE = '/api'; @@ -21,9 +22,8 @@ export async function createCall(payload: { tickers: string[]; date?: string; }): Promise { - const res = await fetch(`${BASE}/calls`, { + const res = await authFetch(`${BASE}/calls`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error(await res.text()); @@ -31,7 +31,7 @@ export async function createCall(payload: { } export async function deleteCall(id: string): Promise<{ ok: boolean }> { - const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); + const res = await authFetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); return res.json(); } diff --git a/ui/src/lib/api/finance.ts b/ui/src/lib/api/finance.ts index aacb89c..54556e3 100644 --- a/ui/src/lib/api/finance.ts +++ b/ui/src/lib/api/finance.ts @@ -1,4 +1,5 @@ import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js'; +import { authFetch } from './auth.js'; const BASE = '/api'; @@ -9,7 +10,7 @@ export async function fetchPortfolio(): Promise<{ netWorth: number | null; error?: string; }> { - const res = await fetch(`${BASE}/finance/portfolio`); + const res = await authFetch(`${BASE}/finance/portfolio`); if (!res.ok) throw new Error(await res.text()); return res.json(); } @@ -17,9 +18,8 @@ export async function fetchPortfolio(): Promise<{ export async function addHolding( holding: PortfolioHolding, ): Promise<{ holdings: PortfolioHolding[] }> { - const res = await fetch(`${BASE}/finance/holdings`, { + const res = await authFetch(`${BASE}/finance/holdings`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(holding), }); if (!res.ok) throw new Error(await res.text()); @@ -27,15 +27,13 @@ export async function addHolding( } export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> { - const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { - method: 'DELETE', - }); + const res = await authFetch(`${BASE}/finance/holdings/${ticker}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); return res.json(); } export async function fetchMarketContext(): Promise { - const res = await fetch(`${BASE}/finance/market-context`); + const res = await authFetch(`${BASE}/finance/market-context`); if (!res.ok) throw new Error(await res.text()); return res.json(); } diff --git a/ui/src/lib/api/index.ts b/ui/src/lib/api/index.ts index 2300a71..6a2368b 100644 --- a/ui/src/lib/api/index.ts +++ b/ui/src/lib/api/index.ts @@ -5,3 +5,4 @@ export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js'; export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js'; export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js'; +export { login, register, authFetch } from './auth.js'; diff --git a/ui/src/lib/components/portfolio/AccountsTable.svelte b/ui/src/lib/components/portfolio/AccountsTable.svelte index f3dce0b..071f931 100644 --- a/ui/src/lib/components/portfolio/AccountsTable.svelte +++ b/ui/src/lib/components/portfolio/AccountsTable.svelte @@ -29,7 +29,7 @@

Accounts

- +
{#each pf.accounts as a} @@ -46,7 +46,7 @@

Spending — Last 30 Days

-
AccountTypeInstitutionBalance
+
{#each pf.categoryBreakdown.slice(0, 10) as c} diff --git a/ui/src/lib/components/portfolio/AdviceTable.svelte b/ui/src/lib/components/portfolio/AdviceTable.svelte index 38f820d..50a903b 100644 --- a/ui/src/lib/components/portfolio/AdviceTable.svelte +++ b/ui/src/lib/components/portfolio/AdviceTable.svelte @@ -118,7 +118,7 @@

Holdings — Hold / Sell / Add Advice

-
CategoryAmount%Share
+
@@ -168,7 +168,7 @@ - + so that position:sticky td:first-child +// inherits it (sticky cells create their own stacking context). +.summary-row { + cursor: pointer; + + &:hover { background: #1e2f48; } + + &.row-open { + background: #1a2f4a; + + td { border-bottom: none !important; } + + // Brighter left accent on the expand cell + .col-expand { + box-shadow: inset 3px 0 0 #60a5fa; + } + } +} + +// ── Inline detail row ──────────────────────────────────────────────────── + +.detail-row td { + padding: 0 !important; + border-bottom: none !important; + box-shadow: inset 0 -3px 0 #1e3a5f; +} +.detail-cell { padding: 0 !important; } + +// Two-column layout: left = metrics (55%), right = bar chart (45%) +// min-width: 0 on each child prevents grid blowout +.detail-panel { + display: grid; + grid-template-columns: 55% 45%; + gap: 0; + background: #0c1829; + border-top: 2px solid var(--blue); + border-left: 3px solid var(--blue); + overflow: hidden; +} + +// ── Left zone ──────────────────────────────────────────────────────────── +.dp-left { + min-width: 0; + padding: 16px 18px 16px 16px; + border-right: 1px solid #1e3a5f; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +// ── Right zone ─────────────────────────────────────────────────────────── +.dp-right { + min-width: 0; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +// ── Section title ───────────────────────────────────────────────────────── +.dp-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #4a7aaa; + margin-bottom: 2px; +} + +.dp-mode-note { + font-size: 10px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: #3a5a7a; + margin-left: 4px; +} + +// ── Metric grid cards ───────────────────────────────────────────────────── +// Fixed 4-col grid on left panel — no min-width auto-fill to prevent overflow +.dp-metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; +} + +.dp-metric-card { + min-width: 0; + background: #0f2040; + border: 1px solid #1a3050; + border-radius: 6px; + padding: 5px 7px; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.dp-mc-label { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #3d5a7a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dp-mc-value { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Gate badge chips ────────────────────────────────────────────────────── +.dp-gates-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.dp-gate-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + padding: 3px 10px; + border-radius: var(--radius-pill); + border: 1px solid transparent; +} + +.dp-gate-chip-pass { + background: #14532d33; + color: var(--green); + border-color: #14532d66; +} + +.dp-gate-chip-fail { + background: #450a0a33; + color: var(--red); + border-color: #450a0a66; +} + +// ── Risk flag pills ─────────────────────────────────────────────────────── +.dp-risk-row { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.dp-risk-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 2px 9px; + border-radius: var(--radius-pill); + background: #431a0033; + color: #fb923c; + border: 1px solid #431a0066; + white-space: nowrap; +} + +// ── Horizontal bar chart (factor scores) ───────────────────────────────── +.dp-bar-chart { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dp-bar-row { + display: grid; + grid-template-columns: 88px 1fr 36px; + align-items: center; + gap: 10px; +} + +.dp-bar-label { + font-size: 12px; + color: #6b8aad; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dp-bar-track { + height: 10px; + background: #0f2040; + border-radius: 3px; + overflow: hidden; + border: 1px solid #1a3050; +} + +.dp-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; + + &.dp-bar-pos { + background: linear-gradient(90deg, #16a34a, #4ade80); + box-shadow: 0 0 6px #4ade8044; + } + + &.dp-bar-neg { + background: linear-gradient(90deg, #991b1b, #f87171); + box-shadow: 0 0 6px #f8717144; + } +} + +.dp-bar-val { + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-align: right; +} + +// ── Gate failures (shown instead of bars when gates fail) ───────────────── +.dp-failures { + display: flex; + flex-direction: column; + gap: 5px; +} + +.dp-failure-item { + font-size: 13px; + color: #f87171; + line-height: 1.5; +} + +.dp-no-factors { + font-size: 12px; + color: #3a5a7a; + font-style: italic; +} + + +/* score-cell base — layout only; visual rules are in the dot-scale block above */ + +/* Classification tags */ +.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); } +.style-tag { color: var(--text-muted); } + +/* Signed % colouring */ +.pos { color: var(--green); } +.neg { color: var(--red); } + +/* Analyst label — not a number */ +.analyst-cell { + font-size: var(--fs-sm); + color: var(--text-muted); + white-space: nowrap; +} + +/* .flag / .flag-more removed — replaced by .flags-badge hover-expand system */ + +// ── MarketContext (collapsible card) ────────────────────────────────────── + +.ctx-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 10px; + padding: var(--space-xl); +} + +.ctx-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: 12px var(--space-lg); +} + +.ctx-label { + font-size: var(--fs-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dimmer); + margin-bottom: 4px; +} + +.ctx-value { + font-size: var(--fs-lg); + font-weight: 700; + color: var(--text-primary); +} + +.ctx-sub { + font-size: var(--fs-xs); + color: var(--text-dim); + margin-top: 2px; +} + +.ctx-toggle { + margin-left: auto; + background: none; + border: none; + color: var(--text-dimmer); + font-size: var(--fs-sm); + cursor: pointer; + padding: 2px 8px; + + &:hover { color: var(--text-muted); } +} + +// ── MarketContextStrip — colorful bubbles ───────────────────────────────── + +.bubble-strip { + display: flex; + flex-wrap: nowrap; + gap: 8px; + margin: 8px 0; +} + +.bubble { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; + padding: 10px 8px; + border-radius: 14px; + border: 1px solid transparent; + gap: 2px; + transition: transform 0.12s ease, box-shadow 0.12s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 18px #0006; + } +} + +.bubble-val { + font-size: 15px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + line-height: 1; +} + +.bubble-label { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.72; + margin-top: 3px; +} + +// Individual color themes +.bubble-indigo { background: #312e8133; border-color: #4f46e566; color: #a5b4fc; } +.bubble-rose { background: #4c0d1533; border-color: #e1184866; color: #fda4af; } +.bubble-emerald { background: #05401a33; border-color: #10b98166; color: #6ee7b7; } +.bubble-sky { background: #082f4933; border-color: #0ea5e966; color: #7dd3fc; } +.bubble-violet { background: #2e1a6e33; border-color: #7c3aed66; color: #c4b5fd; } +.bubble-amber { background: #451a0333; border-color: #f59e0b66; color: #fcd34d; } +.bubble-teal { background: #042f2e33; border-color: #14b8a666; color: #5eead4; } +.bubble-slate { background: #1e293b; border-color: #47556966; color: #94a3b8; } +.bubble-red { background: #450a0a33; border-color: #ef444466; color: #fca5a5; } +.bubble-blue { background: #1e3a5f33; border-color: #3b82f666; color: #93c5fd; } +.bubble-orange { background: #431a0333; border-color: #f9731666; color: #fdba74; } + +// ── MarketContext (collapsible card grid) ───────────────────────────────── + +.ctx-wrap { margin-bottom: 20px; } + +.ctx-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 12px; + cursor: pointer; + margin-bottom: 10px; +} + +.ctx-toggle-label { + font-size: var(--fs-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dimmer); +} + +.ctx-toggle-chevron { font-size: var(--fs-2xs); color: var(--text-faint); } + +.ctx-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + margin-bottom: 8px; +} + +.ctx-card { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); } + +.ctx-label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; } +.ctx-card-label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } + +// Tooltip +.tip-wrap { position: relative; display: inline-flex; flex-shrink: 0; } + +.tip-anchor { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--bg-card); + border: 1px solid var(--text-faint); + color: var(--text-dimmer); + font-size: var(--fs-2xs); + font-weight: 700; + cursor: help; +} + +.tip-box { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + width: 220px; + background: var(--bg-card); + border: 1px solid var(--text-faint); + border-radius: var(--radius-sm); + padding: 8px 10px; + font-size: var(--fs-sm); + color: var(--text-muted); + line-height: 1.5; + z-index: 50; + pointer-events: none; + white-space: normal; + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--text-faint); + } +} + +.tip-wrap:hover .tip-box { display: block; } + +.ctx-value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; } diff --git a/ui/src/styles/_table.scss b/ui/src/styles/_table.scss index 85a4da6..ddaebc8 100644 --- a/ui/src/styles/_table.scss +++ b/ui/src/styles/_table.scss @@ -17,7 +17,7 @@ table { font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--text-faint); + color: var(--text-dim); border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--bg-elevated); @@ -27,11 +27,7 @@ table { tr { border-bottom: 1px solid var(--border-subtle); - &:hover { - background: var(--bg-card-hover); - - td:first-child { background: var(--bg-card-hover); } - } + &:hover { background: var(--bg-card-hover); } } td { @@ -40,11 +36,12 @@ table { white-space: nowrap; font-size: var(--fs-md); - // Sticky first column (body) + // Sticky first column — inherits row background so hover/select states + // paint through correctly across the full row width &:first-child { position: sticky; left: 0; - background: var(--bg-surface); + background: inherit; z-index: 1; } } diff --git a/ui/src/styles/app.scss b/ui/src/styles/app.scss index b3fcce9..bbe8d9c 100644 --- a/ui/src/styles/app.scss +++ b/ui/src/styles/app.scss @@ -13,3 +13,4 @@ @use 'sidebar'; @use 'calls'; @use 'portfolio'; +@use 'screener';
toggleSort('ticker')}>Ticker {sortIcon('ticker')}{a.gainLossPct != null ? a.gainLossPct + '%' : '—'} {#if a.signal}{:else}{/if} {a.advice}{a.reason}{a.reason} {#if isEditing} diff --git a/ui/src/lib/components/screener/AssetTable.svelte b/ui/src/lib/components/screener/AssetTable.svelte index 53719e8..71e9c81 100644 --- a/ui/src/lib/components/screener/AssetTable.svelte +++ b/ui/src/lib/components/screener/AssetTable.svelte @@ -1,7 +1,5 @@

{type}S

- {rows.length} + {filteredRows(rows).length === rows.length ? rows.length : `${filteredRows(rows).length} / ${rows.length}`} + {#if hasFilter()} + + {/if}
@@ -54,123 +202,312 @@
- +
+ - - - - + + + + + {#if type === 'STOCK'} - - - - - - - - - - - - - - - - - - - - - + + {:else if type === 'ETF'} - + + {:else} - + + + {/if} + + + + + + + + + + {#if type === 'STOCK'} + + + + {:else} + + {/if} - {#each sorted(rows) as r} + {#each sortedRows(rows) as r} {@const m = r.asset.displayMetrics ?? {}} {@const v = r[mode as 'inflated' | 'fundamental']} - + {@const isOpen = expanded === r.asset.ticker} + {@const colCount = type === 'STOCK' ? 8 : 7} + {@const flags = v.audit?.riskFlags ?? []} + {@const rawScore = parseInt(v.scoreSummary?.replace(/\D/g, '') ?? '0', 10)} + + + toggleExpand(r.asset.ticker)} + > + - - + + + + {#if type === 'STOCK'} - - - - - - - - - - - - - - - - - - - - - + + {:else if type === 'ETF'} - - - - + + {:else} - - - + + {/if} + + + {#if isOpen} + {@const mktPass = r.inflated.audit?.passedGates} + {@const grahamPass = r.fundamental.audit?.passedGates} + + + + {/if} + {/each}
TickerPriceVerdictScore setSort('ticker')}> + Ticker {sortIcon('ticker')} + setSort('price')}> + Price {sortIcon('price')} + setSort('signal')}> + Signal {sortIcon('signal')} + setSort('score')}> + Score {sortIcon('score')} + CapStyleP/EPEGGrossM%ROE%OpMgn%FCF%D/E52W ChgFrom HighAnalystUpsideDCF Safety setSort('cap')}> + Cap {sortIcon('cap')} + Style FlagsExpenseYieldAUM5Y Ret setSort('expense')}> + Expense {sortIcon('expense')} + setSort('ret5y')}> + 5Y Ret {sortIcon('ret5y')} + YTMDurationRating setSort('rating')}> + Rating {sortIcon('rating')} + setSort('ytm')}> + YTM {sortIcon('ytm')} +
+ + +
+ + +
+
+ + + + + + + + + +
{isOpen ? '▾' : '▸'} {r.asset.ticker} {m.Price ?? '—'}{v.scoreSummary} + {(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'} + + {#if v.scoreSummary?.startsWith('Gate failed')} + + {:else} + + {#each Array(5) as _, i} + + {/each} + + {rawScore} + {/if} + {m['Cap Tier'] ?? '—'}{m['Style'] ?? '—'}{m['P/E'] ?? '—'}{m['PEG'] ?? '—'}{m['GrossM%'] ?? '—'}{m['ROE%'] ?? '—'}{m['OpMgn%'] ?? '—'}{m['FCF Yld%'] ?? '—'}{m['D/E'] ?? '—'}{m['52W Chg'] ?? '—'}{m['From High'] ?? '—'}{m['Analyst'] ?? '—'}{m['Upside'] ?? '—'}{m['DCF Safety'] ?? '—'} - {#each v.audit?.riskFlags ?? [] as flag} - ⚠ {flag} - {/each} + {m['Style'] ?? '—'} + {#if flags.length > 0} + + ⚠ {flags.length} + + {/if} {m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}{m['Exp Ratio%'] ?? '—'}{m['5Y Return%'] ?? '—'}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}{m['Rating'] ?? '—'}{m['YTM%'] ?? '—'}
+
+ + +
+
Metrics
+
+ {#if type === 'STOCK'} +
+ P/E + {m['P/E'] ?? '—'} +
+
+ PEG + {m['PEG'] ?? '—'} +
+
+ ROE% + {m['ROE%'] ?? '—'} +
+
+ Op Mgn% + {m['OpMgn%'] ?? '—'} +
+
+ Gross M% + {m['GrossM%'] ?? '—'} +
+
+ FCF Yld% + {m['FCF Yld%'] ?? '—'} +
+
+ D/E + {m['D/E'] ?? '—'} +
+
+ 52W Chg + {m['52W Chg'] ?? '—'} +
+
+ From High + {m['From High'] ?? '—'} +
+
+ Analyst + {m['Analyst'] ?? '—'} +
+
+ Upside + {m['Upside'] ?? '—'} +
+
+ DCF Safety + {m['DCF Safety'] ?? '—'} +
+ {:else if type === 'ETF'} +
+ Yield% + {m['Yield%'] ?? '—'} +
+
+ AUM + {m['AUM'] ?? '—'} +
+
+ 5Y Ret% + {m['5Y Return%'] ?? '—'} +
+
+ Exp Ratio% + {m['Exp Ratio%'] ?? '—'} +
+ {:else} +
+ YTM% + {m['YTM%'] ?? '—'} +
+
+ Duration + {m['Duration'] ?? '—'} +
+
+ Rating + {m['Rating'] ?? '—'} +
+ {/if} +
+ + +
+ + MKT {mktPass ? '✓' : '✗'} + + + GRAHAM {grahamPass ? '✓' : '✗'} + +
+ + + {#if v.audit?.riskFlags?.length} +
+ {#each v.audit.riskFlags as flag} + ⚠ {flag} + {/each} +
+ {/if} +
+ + +
+
+ Factor Scores + ({mode === 'inflated' ? 'Mkt-Adj' : 'Graham'}) +
+ + {#if !v.audit?.passedGates && v.audit?.failures?.length} + +
+ {#each v.audit.failures as f} +
✗ {f}
+ {/each} +
+ {:else if breakdownEntries(v.audit?.breakdown).length} + {@const entries = breakdownEntries(v.audit?.breakdown)} + {@const scale = maxAbs(v.audit?.breakdown)} +
+ {#each entries as [factor, score]} + {@const pct = Math.round((Math.abs(score) / scale) * 100)} +
+ {factor} +
+
+
+ + {score > 0 ? '+' : ''}{score} + +
+ {/each} +
+ {:else} +
No factor data — gates failed before scoring
+ {/if} +
+ +
+
- - diff --git a/ui/src/lib/components/shared/MarketContext.svelte b/ui/src/lib/components/shared/MarketContext.svelte index 7c2a027..93fdd4b 100644 --- a/ui/src/lib/components/shared/MarketContext.svelte +++ b/ui/src/lib/components/shared/MarketContext.svelte @@ -68,110 +68,19 @@ {/if} {#if expanded} -
+
{#each cards as c} -
-
- {c.label} +
+
+ {c.label} ? {c.tip}
-
{c.value}
+
{c.value}
{/each}
{/if}
- - diff --git a/ui/src/lib/components/shared/MarketContextStrip.svelte b/ui/src/lib/components/shared/MarketContextStrip.svelte index 224f110..9d71b78 100644 --- a/ui/src/lib/components/shared/MarketContextStrip.svelte +++ b/ui/src/lib/components/shared/MarketContextStrip.svelte @@ -3,67 +3,25 @@ import type { MarketContext } from '$lib/types.js'; let { ctx }: { ctx: MarketContext } = $props(); - // Flat list of chips so the template stays declarative + // color: bg, border, text (all as hex/rgba strings) const chips = $derived([ - { label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' }, - { label: 'VIX', value: ctx.vixLevel?.toFixed(1) }, - { label: 'S&P', value: ctx.sp500Price?.toLocaleString() }, - { label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) }, - { label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) }, - { label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' }, - { label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' }, - { label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime }, - { label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime }, + { label: '10Y', value: ctx.riskFreeRate != null ? ctx.riskFreeRate.toFixed(1) + '%' : '—', color: 'indigo' }, + { label: 'VIX', value: ctx.vixLevel != null ? ctx.vixLevel.toFixed(1) : '—', color: 'rose' }, + { label: 'S&P', value: ctx.sp500Price?.toLocaleString() ?? '—', color: 'emerald' }, + { label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE), color: 'sky' }, + { label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE), color: 'violet' }, + { label: 'REIT Yld', value: ctx.benchmarks?.reitYield != null ? ctx.benchmarks.reitYield.toFixed(1) + '%' : '—', color: 'amber' }, + { label: 'IG Sprd', value: ctx.benchmarks?.igSpread != null ? ctx.benchmarks.igSpread.toFixed(2) + '%' : '—', color: 'teal' }, + { label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime, color: ctx.rateRegime === 'HIGH' ? 'red' : ctx.rateRegime === 'LOW' ? 'blue' : 'slate' }, + { label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime, color: ctx.volatilityRegime === 'ELEVATED' ? 'orange' : 'slate' }, ]); -
+
{#each chips as chip} -
- {chip.label} - - {chip.value ?? '—'} - +
+ {chip.value ?? '—'} + {chip.label}
{/each}
- - diff --git a/ui/src/lib/stores/auth.store.svelte.ts b/ui/src/lib/stores/auth.store.svelte.ts new file mode 100644 index 0000000..897d57a --- /dev/null +++ b/ui/src/lib/stores/auth.store.svelte.ts @@ -0,0 +1,71 @@ +/** + * Auth store — holds current user + JWT token. + * Persists token to sessionStorage so it survives page refresh within the tab. + */ + +import type { AuthUser, Role } from '$lib/types.js'; + +const TOKEN_KEY = 'ms_token'; + +function createAuthStore() { + // Hydrate from sessionStorage on first load + const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(TOKEN_KEY) : null; + + let token = $state(stored); + let user = $state(stored ? parseTokenUser(stored) : null); + + function setAuth(newToken: string, newUser: AuthUser) { + token = newToken; + user = newUser; + sessionStorage.setItem(TOKEN_KEY, newToken); + } + + function clearAuth() { + token = null; + user = null; + sessionStorage.removeItem(TOKEN_KEY); + } + + return { + get token() { + return token; + }, + get user() { + return user; + }, + get isLoggedIn() { + return token !== null && user !== null; + }, + get role(): Role | null { + return user?.role ?? null; + }, + get isTrader() { + return user?.role === 'trader' || user?.role === 'admin'; + }, + setAuth, + clearAuth, + }; +} + +/** Decode the JWT payload (base64url middle segment) to extract user info. */ +function parseTokenUser(token: string): AuthUser | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + if (!payload.sub || !payload.email || !payload.role) return null; + // Check expiry + if (payload.exp && payload.exp * 1000 < Date.now()) return null; + return { + id: payload.sub as string, + email: payload.email as string, + role: payload.role as Role, + createdAt: '', + lastLogin: null, + }; + } catch { + return null; + } +} + +export const authStore = createAuthStore(); diff --git a/ui/src/lib/stores/portfolio.store.svelte.ts b/ui/src/lib/stores/portfolio.store.svelte.ts index 3d491e6..d8398da 100644 --- a/ui/src/lib/stores/portfolio.store.svelte.ts +++ b/ui/src/lib/stores/portfolio.store.svelte.ts @@ -1,4 +1,4 @@ -import { addHolding, removeHolding } from '$lib/api.js'; +import { addHolding, removeHolding, authFetch } from '$lib/api.js'; import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js'; interface PortfolioData { @@ -23,8 +23,7 @@ class PortfolioStore { else this.refreshing = true; this.loadError = null; - window - .fetch('/api/finance/portfolio') + authFetch('/api/finance/portfolio') .then((res) => res.ok ? res.json() diff --git a/ui/src/lib/types/ui.types.ts b/ui/src/lib/types/ui.types.ts index 8374b74..1c64300 100644 --- a/ui/src/lib/types/ui.types.ts +++ b/ui/src/lib/types/ui.types.ts @@ -1,6 +1,23 @@ import type { AssetType } from '$types/asset.model.js'; import type { LLMAnalysis } from '$types/finance.model.js'; +// ── Auth types ──────────────────────────────────────────────────────────────── + +export type Role = 'trader' | 'viewer' | 'admin'; + +export interface AuthUser { + id: string; + email: string; + role: Role; + createdAt: string; + lastLogin: string | null; +} + +export interface AuthResponse { + token: string; + user: AuthUser; +} + /** Detailed display metrics rendered per asset row in the screener table. */ export interface AssetDisplayMetrics { // ── Common ────────────────────────────────────────────────────────── diff --git a/ui/src/lib/utils/formatting.ts b/ui/src/lib/utils/formatting.ts index f06794e..066b1b4 100644 --- a/ui/src/lib/utils/formatting.ts +++ b/ui/src/lib/utils/formatting.ts @@ -2,9 +2,9 @@ * Number and currency formatting utilities. */ -/** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */ +/** Formats a P/E ratio — e.g. 26.72091 → "26.7x", null → "—" */ export function fmtPE(v: number | null | undefined): string { - return v != null ? v + 'x' : '—'; + return v != null ? v.toFixed(1) + 'x' : '—'; } /** Full currency format — e.g. 1234.5 → "$1,234.50" */ diff --git a/ui/src/lib/utils/verdicts.ts b/ui/src/lib/utils/verdicts.ts index 1a9e890..77ea5af 100644 --- a/ui/src/lib/utils/verdicts.ts +++ b/ui/src/lib/utils/verdicts.ts @@ -9,25 +9,49 @@ */ export function verdictShort(label: string | null | undefined): string { if (!label) return '—'; - if (label.includes('High Conviction')) return 'Strong'; + if (label.includes('High Conviction')) return 'Strong Buy'; if (label.includes('Speculative')) return 'Speculative'; + if (label.includes('Momentum')) return 'Momentum'; if (label.includes('BUY')) return 'Buy'; if (label.includes('Efficient')) return 'Efficient'; if (label.includes('Attractive')) return 'Attractive'; if (label.includes('Neutral')) return 'Hold'; if (label.includes('REJECT')) return 'Reject'; if (label.includes('Avoid')) return 'Avoid'; - return label.replace(/[🟢🟡🔴]/u, '').trim(); + return label.replace(/[\u{1F7E2}\u{1F7E1}\u{1F534}]/u, '').trim(); } /** - * Returns a CSS colour class ('green' | 'yellow' | 'red') based on - * the emoji prefix of a verdict label. + * Returns a CSS colour class based on the verdict label content. + * + * Signal mapping: + * 🟢 / High Conviction / Efficient / Attractive → green + * 🟡 / Speculative / Momentum → yellow + * Neutral / Hold / no signal → blue (calm, not alarming) + * 🔴 / Avoid / Reject / REJECT → red */ -export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' { - if (label?.startsWith('🟢')) return 'green'; - if (label?.startsWith('🟡')) return 'yellow'; - return 'red'; +export function vClass( + label: string | null | undefined, +): 'green' | 'yellow' | 'red' | 'blue' | 'gray' { + if (!label) return 'gray'; + if ( + label.startsWith('🟢') || + label.includes('High Conviction') || + label.includes('Efficient') || + label.includes('Attractive') + ) + return 'green'; + if (label.startsWith('🟡') || label.includes('Speculative') || label.includes('Momentum')) + return 'yellow'; + if ( + label.startsWith('🔴') || + label.includes('Avoid') || + label.includes('Reject') || + label.includes('REJECT') + ) + return 'red'; + if (label.includes('Neutral') || label.includes('Hold') || label.includes('BUY')) return 'blue'; + return 'gray'; } /** diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index d9ab9ea..67e18c2 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -1,7 +1,9 @@ -
+
- + +

+ Back to sign in +

+ {/if} + +
+ + diff --git a/ui/src/routes/auth/login/+page.svelte b/ui/src/routes/auth/login/+page.svelte new file mode 100644 index 0000000..88984c6 --- /dev/null +++ b/ui/src/routes/auth/login/+page.svelte @@ -0,0 +1,120 @@ + + + + + diff --git a/ui/src/routes/auth/login/+page.ts b/ui/src/routes/auth/login/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/ui/src/routes/auth/login/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/ui/src/routes/auth/register/+page.svelte b/ui/src/routes/auth/register/+page.svelte new file mode 100644 index 0000000..f994f57 --- /dev/null +++ b/ui/src/routes/auth/register/+page.svelte @@ -0,0 +1,144 @@ + + + + + diff --git a/ui/src/routes/auth/register/+page.ts b/ui/src/routes/auth/register/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/ui/src/routes/auth/register/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/ui/src/routes/auth/reset-password/+page.svelte b/ui/src/routes/auth/reset-password/+page.svelte new file mode 100644 index 0000000..067ab8b --- /dev/null +++ b/ui/src/routes/auth/reset-password/+page.svelte @@ -0,0 +1,157 @@ + + + + + diff --git a/ui/src/routes/safe-buys/+page.svelte b/ui/src/routes/safe-buys/+page.svelte index 0365ebc..44a81c1 100644 --- a/ui/src/routes/safe-buys/+page.svelte +++ b/ui/src/routes/safe-buys/+page.svelte @@ -27,7 +27,7 @@ const totalStrong = $derived(strongEtfs.length + strongBonds.length); -
+