phase-10.5: market screener ui enhancements

This commit is contained in:
Kazuma
2026-06-09 01:21:02 -04:00
parent 7bc242911e
commit fbadd7fb6e
45 changed files with 3054 additions and 539 deletions
+146
View File
@@ -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 });
}
}
}
+146
View File
@@ -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);
}
}
+68
View File
@@ -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,
};
}
}
+36
View File
@@ -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;
}
+4
View File
@@ -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';