/** * 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 }); } } }