From 127b19b4e1c7628d2b2f995cfd02524cc7a42fe2 Mon Sep 17 00:00:00 2001 From: Fredrik Jensen Date: Sat, 7 Feb 2026 18:05:59 +0100 Subject: [PATCH] fix issues and add validation --- .claude/settings.local.json | 3 +- src/app.ts | 5 ++ src/db/index.ts | 2 +- src/db/schema.ts | 6 +- src/lib/otp.ts | 28 +++----- src/plugins/error-handler.ts | 5 +- src/routes/accounts.ts | 17 +++-- src/routes/login.ts | 40 +++++++---- src/routes/me.ts | 23 ++++--- src/routes/sessions.ts | 129 ++++++++++++++++++++--------------- src/routes/signup.ts | 19 ++++-- src/server.ts | 9 +++ 12 files changed, 176 insertions(+), 110 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index defa98d..c4b5384 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(pnpm db:migrate:*)", "Bash(psql:*)", "Bash(pnpm test:*)", - "Bash(pnpm vitest run:*)" + "Bash(pnpm vitest run:*)", + "Bash(npx tsc:*)" ] } } diff --git a/src/app.ts b/src/app.ts index 535f921..0eae2fa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import Fastify from "fastify"; +import { client } from "./db/index.js"; import { errorHandler } from "./plugins/error-handler.js"; import { authenticate } from "./plugins/authenticate.js"; import { accountContext } from "./plugins/account-context.js"; @@ -9,6 +10,10 @@ export function buildApp() { logger: true, }); + app.addHook("onClose", async () => { + await client.end(); + }); + app.register(errorHandler); app.register(authenticate); app.register(accountContext); diff --git a/src/db/index.ts b/src/db/index.ts index 549f74a..53f7f2f 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -3,6 +3,6 @@ import postgres from "postgres"; import { config } from "../config.js"; import * as schema from "./schema.js"; -const client = postgres(config.DATABASE_URL); +export const client = postgres(config.DATABASE_URL); export const db = drizzle(client, { schema }); diff --git a/src/db/schema.ts b/src/db/schema.ts index f27b7bb..61e30c9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,6 @@ -import { boolean, integer, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { boolean, integer, pgEnum, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; + +export const membershipRoleEnum = pgEnum("membership_role", ["owner", "admin", "member"]); export const users = pgTable("users", { id: uuid().defaultRandom().primaryKey(), @@ -35,7 +37,7 @@ export const memberships = pgTable( accountId: uuid("account_id") .notNull() .references(() => accounts.id), - role: text().notNull(), + role: membershipRoleEnum().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, (t) => [unique().on(t.userId, t.accountId)], diff --git a/src/lib/otp.ts b/src/lib/otp.ts index 31348ac..29d991d 100644 --- a/src/lib/otp.ts +++ b/src/lib/otp.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { eq, and, gt } from "drizzle-orm"; +import { eq, and, gt, lt, sql } from "drizzle-orm"; import { db } from "../db/index.js"; import { otpCodes } from "../db/schema.js"; @@ -30,36 +30,30 @@ function sendOtp(email: string, code: string): void { console.log(`[OTP] Code for ${email}: ${code}`); } -/** Validate an OTP code for the given email. Returns the email on success, throws on failure. */ +/** Validate an OTP code for the given email. Throws on failure. */ export async function verifyOtp(email: string, code: string): Promise { + // Atomically increment attempts and return the row, preventing concurrent verification const [otp] = await db - .select() - .from(otpCodes) + .update(otpCodes) + .set({ attempts: sql`${otpCodes.attempts} + 1` }) .where( and( eq(otpCodes.email, email), eq(otpCodes.used, false), gt(otpCodes.expiresAt, new Date()), + lt(otpCodes.attempts, OTP_MAX_ATTEMPTS), ), ) - .orderBy(otpCodes.createdAt) - .limit(1); + .returning(); if (!otp) { throw new OtpError("Invalid or expired code"); } - if (otp.attempts >= OTP_MAX_ATTEMPTS) { - await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id)); - throw new OtpError("Too many attempts. Request a new code."); - } - - await db - .update(otpCodes) - .set({ attempts: otp.attempts + 1 }) - .where(eq(otpCodes.id, otp.id)); - - if (otp.code !== code) { + const codeMatch = + otp.code.length === code.length && + crypto.timingSafeEqual(Buffer.from(otp.code), Buffer.from(code)); + if (!codeMatch) { throw new OtpError("Invalid or expired code"); } diff --git a/src/plugins/error-handler.ts b/src/plugins/error-handler.ts index 339bcab..bec70c6 100644 --- a/src/plugins/error-handler.ts +++ b/src/plugins/error-handler.ts @@ -1,6 +1,7 @@ +import fp from "fastify-plugin"; import type { FastifyInstance, FastifyError } from "fastify"; -export async function errorHandler(app: FastifyInstance) { +async function errorHandlerPlugin(app: FastifyInstance) { app.setErrorHandler((error: FastifyError, _request, reply) => { const statusCode = error.statusCode ?? 500; @@ -13,3 +14,5 @@ export async function errorHandler(app: FastifyInstance) { }); }); } + +export const errorHandler = fp(errorHandlerPlugin, { name: "error-handler" }); diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts index 525454c..163ba53 100644 --- a/src/routes/accounts.ts +++ b/src/routes/accounts.ts @@ -12,15 +12,22 @@ export async function accountRoutes(app: FastifyInstance) { // POST /accounts — create a new account (user becomes owner) app.post<{ Body: { name: string } }>( "/accounts", - { preHandler: [app.authenticate] }, + { + preHandler: [app.authenticate], + schema: { + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1 }, + }, + }, + }, + }, async (request, reply) => { const userId = request.user.sub; const { name } = request.body; - if (!name || typeof name !== "string") { - return reply.status(400).send({ error: "Account name is required" }); - } - const account = await db.transaction(async (tx) => { const [created] = await tx.insert(accounts).values({ name }).returning(); await tx.insert(memberships).values({ diff --git a/src/routes/login.ts b/src/routes/login.ts index 06b8cf5..8265c8a 100644 --- a/src/routes/login.ts +++ b/src/routes/login.ts @@ -3,22 +3,32 @@ import { requestOtp, OtpRateLimitError } from "../lib/otp.js"; export async function loginRoutes(app: FastifyInstance) { // POST /login — send OTP to email - app.post<{ Body: { email: string } }>("/login", async (request, reply) => { - const { email } = request.body; + app.post<{ Body: { email: string } }>( + "/login", + { + schema: { + body: { + type: "object", + required: ["email"], + properties: { + email: { type: "string", format: "email" }, + }, + }, + }, + }, + async (request, reply) => { + const { email } = request.body; - if (!email || typeof email !== "string") { - return reply.status(400).send({ error: "Email is required" }); - } - - try { - await requestOtp(email); - } catch (err) { - if (err instanceof OtpRateLimitError) { - return reply.status(429).send({ error: err.message }); + try { + await requestOtp(email); + } catch (err) { + if (err instanceof OtpRateLimitError) { + return reply.status(429).send({ error: err.message }); + } + throw err; } - throw err; - } - return { message: "OTP sent to your email" }; - }); + return { message: "OTP sent to your email" }; + }, + ); } diff --git a/src/routes/me.ts b/src/routes/me.ts index 786606a..3a633af 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -20,20 +20,27 @@ export async function meRoutes(app: FastifyInstance) { }); // PATCH /me — update current user's profile (authenticated) - app.patch<{ Body: { email?: string; name?: string } }>( + app.patch<{ Body: { name: string } }>( "/me", - { preHandler: [app.authenticate] }, + { + preHandler: [app.authenticate], + schema: { + body: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", minLength: 1 }, + }, + }, + }, + }, async (request, reply) => { const userId = request.user.sub; - const { email, name } = request.body; - - if (!email && !name) { - return reply.status(400).send({ error: "Nothing to update" }); - } + const { name } = request.body; const [user] = await db .update(users) - .set({ ...(email && { email }), ...(name && { name }), updatedAt: new Date() }) + .set({ name, updatedAt: new Date() }) .where(eq(users.id, userId)) .returning(); diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts index 524b61e..5f72e3d 100644 --- a/src/routes/sessions.ts +++ b/src/routes/sessions.ts @@ -8,77 +8,98 @@ import { getUserAccounts } from "../lib/accounts.js"; export async function sessionRoutes(app: FastifyInstance) { // POST /sessions — validate OTP, create session for existing user - app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => { - const { email, code } = request.body; + app.post<{ Body: { email: string; code: string } }>( + "/", + { + schema: { + body: { + type: "object", + required: ["email", "code"], + properties: { + email: { type: "string", format: "email" }, + code: { type: "string", minLength: 6, maxLength: 6 }, + }, + }, + }, + }, + async (request, reply) => { + const { email, code } = request.body; - if (!email || !code) { - return reply.status(400).send({ error: "Email and code are required" }); - } - - try { - await verifyOtp(email, code); - } catch (err) { - if (err instanceof OtpError) { - return reply.status(400).send({ error: err.message }); + try { + await verifyOtp(email, code); + } catch (err) { + if (err instanceof OtpError) { + return reply.status(400).send({ error: err.message }); + } + throw err; } - throw err; - } - const [user] = await db.select().from(users).where(eq(users.email, email)); - if (!user) { - return reply.status(401).send({ error: "No account found. Please sign up." }); - } + const [user] = await db.select().from(users).where(eq(users.email, email)); + if (!user) { + return reply.status(401).send({ error: "No account found. Please sign up." }); + } - const tokens = await createSession(user.id); - const userAccounts = await getUserAccounts(user.id); + const tokens = await createSession(user.id); + const userAccounts = await getUserAccounts(user.id); - return { ...tokens, user, accounts: userAccounts }; - }); + return { ...tokens, user, accounts: userAccounts }; + }, + ); // POST /sessions/refresh — rotate refresh token, return new tokens - app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => { - const { refreshToken } = request.body; + app.post<{ Body: { refreshToken: string } }>( + "/refresh", + { + schema: { + body: { + type: "object", + required: ["refreshToken"], + properties: { + refreshToken: { type: "string", minLength: 1 }, + }, + }, + }, + }, + async (request, reply) => { + const { refreshToken } = request.body; - if (!refreshToken) { - return reply.status(400).send({ error: "Refresh token is required" }); - } + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.refreshToken, refreshToken)); - const [session] = await db - .select() - .from(sessions) - .where(eq(sessions.refreshToken, refreshToken)); + if (!session) { + return reply.status(401).send({ error: "Invalid refresh token" }); + } - if (!session) { - return reply.status(401).send({ error: "Invalid refresh token" }); - } + // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection) + if (session.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt))); + return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." }); + } - // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection) - if (session.revokedAt) { + // Check expiry + if (session.expiresAt < new Date()) { + return reply.status(401).send({ error: "Refresh token expired" }); + } + + // Revoke old session await db .update(sessions) .set({ revokedAt: new Date() }) - .where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt))); - return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." }); - } + .where(eq(sessions.id, session.id)); - // Check expiry - if (session.expiresAt < new Date()) { - return reply.status(401).send({ error: "Refresh token expired" }); - } + const tokens = await createSession(session.userId); - // Revoke old session - await db - .update(sessions) - .set({ revokedAt: new Date() }) - .where(eq(sessions.id, session.id)); + const [user] = await db.select().from(users).where(eq(users.id, session.userId)); + const userAccounts = await getUserAccounts(session.userId); - const tokens = await createSession(session.userId); - - const [user] = await db.select().from(users).where(eq(users.id, session.userId)); - const userAccounts = await getUserAccounts(session.userId); - - return { ...tokens, user, accounts: userAccounts }; - }); + return { ...tokens, user, accounts: userAccounts }; + }, + ); // POST /sessions/logout — revoke all sessions (authenticated) app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => { diff --git a/src/routes/signup.ts b/src/routes/signup.ts index 42b620c..ba28ab9 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -10,15 +10,22 @@ export async function signupRoutes(app: FastifyInstance) { // POST /signup — validate OTP, create user + account, return tokens app.post<{ Body: { email: string; code: string; accountName: string } }>( "/signup", + { + schema: { + body: { + type: "object", + required: ["email", "code", "accountName"], + properties: { + email: { type: "string", format: "email" }, + code: { type: "string", minLength: 6, maxLength: 6 }, + accountName: { type: "string", minLength: 1 }, + }, + }, + }, + }, async (request, reply) => { const { email, code, accountName } = request.body; - if (!email || !code || !accountName) { - return reply - .status(400) - .send({ error: "Email, code, and accountName are required" }); - } - try { await verifyOtp(email, code); } catch (err) { diff --git a/src/server.ts b/src/server.ts index b418254..de51402 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,15 @@ import { config } from "./config.js"; const app = buildApp(); +const shutdown = async () => { + app.log.info("Shutting down..."); + await app.close(); + process.exit(0); +}; + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + app.listen({ port: config.PORT, host: config.HOST }, (err) => { if (err) { app.log.error(err);