phase-10.5: market screener ui enhancements
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<UserRow>(USER_QUERIES.SELECT_BY_EMAIL, [email]);
|
||||
}
|
||||
|
||||
findById(id: string): User | undefined {
|
||||
const row = this.db.rawGet<UserRow>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<T = Record<string, unknown>>(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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)`,
|
||||
];
|
||||
|
||||
@@ -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<HoldingRow>(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,
|
||||
|
||||
Reference in New Issue
Block a user