fix issues and add validation

This commit is contained in:
Fredrik Jensen 2026-02-07 18:05:59 +01:00
parent 60f870ece8
commit 127b19b4e1
12 changed files with 176 additions and 110 deletions

View File

@ -10,7 +10,8 @@
"Bash(pnpm db:migrate:*)", "Bash(pnpm db:migrate:*)",
"Bash(psql:*)", "Bash(psql:*)",
"Bash(pnpm test:*)", "Bash(pnpm test:*)",
"Bash(pnpm vitest run:*)" "Bash(pnpm vitest run:*)",
"Bash(npx tsc:*)"
] ]
} }
} }

View File

@ -1,4 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { client } from "./db/index.js";
import { errorHandler } from "./plugins/error-handler.js"; import { errorHandler } from "./plugins/error-handler.js";
import { authenticate } from "./plugins/authenticate.js"; import { authenticate } from "./plugins/authenticate.js";
import { accountContext } from "./plugins/account-context.js"; import { accountContext } from "./plugins/account-context.js";
@ -9,6 +10,10 @@ export function buildApp() {
logger: true, logger: true,
}); });
app.addHook("onClose", async () => {
await client.end();
});
app.register(errorHandler); app.register(errorHandler);
app.register(authenticate); app.register(authenticate);
app.register(accountContext); app.register(accountContext);

View File

@ -3,6 +3,6 @@ import postgres from "postgres";
import { config } from "../config.js"; import { config } from "../config.js";
import * as schema from "./schema.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 }); export const db = drizzle(client, { schema });

View File

@ -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", { export const users = pgTable("users", {
id: uuid().defaultRandom().primaryKey(), id: uuid().defaultRandom().primaryKey(),
@ -35,7 +37,7 @@ export const memberships = pgTable(
accountId: uuid("account_id") accountId: uuid("account_id")
.notNull() .notNull()
.references(() => accounts.id), .references(() => accounts.id),
role: text().notNull(), role: membershipRoleEnum().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, },
(t) => [unique().on(t.userId, t.accountId)], (t) => [unique().on(t.userId, t.accountId)],

View File

@ -1,5 +1,5 @@
import crypto from "node:crypto"; 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 { db } from "../db/index.js";
import { otpCodes } from "../db/schema.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}`); 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<void> { export async function verifyOtp(email: string, code: string): Promise<void> {
// Atomically increment attempts and return the row, preventing concurrent verification
const [otp] = await db const [otp] = await db
.select() .update(otpCodes)
.from(otpCodes) .set({ attempts: sql`${otpCodes.attempts} + 1` })
.where( .where(
and( and(
eq(otpCodes.email, email), eq(otpCodes.email, email),
eq(otpCodes.used, false), eq(otpCodes.used, false),
gt(otpCodes.expiresAt, new Date()), gt(otpCodes.expiresAt, new Date()),
lt(otpCodes.attempts, OTP_MAX_ATTEMPTS),
), ),
) )
.orderBy(otpCodes.createdAt) .returning();
.limit(1);
if (!otp) { if (!otp) {
throw new OtpError("Invalid or expired code"); throw new OtpError("Invalid or expired code");
} }
if (otp.attempts >= OTP_MAX_ATTEMPTS) { const codeMatch =
await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id)); otp.code.length === code.length &&
throw new OtpError("Too many attempts. Request a new code."); crypto.timingSafeEqual(Buffer.from(otp.code), Buffer.from(code));
} if (!codeMatch) {
await db
.update(otpCodes)
.set({ attempts: otp.attempts + 1 })
.where(eq(otpCodes.id, otp.id));
if (otp.code !== code) {
throw new OtpError("Invalid or expired code"); throw new OtpError("Invalid or expired code");
} }

View File

@ -1,6 +1,7 @@
import fp from "fastify-plugin";
import type { FastifyInstance, FastifyError } from "fastify"; import type { FastifyInstance, FastifyError } from "fastify";
export async function errorHandler(app: FastifyInstance) { async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((error: FastifyError, _request, reply) => { app.setErrorHandler((error: FastifyError, _request, reply) => {
const statusCode = error.statusCode ?? 500; const statusCode = error.statusCode ?? 500;
@ -13,3 +14,5 @@ export async function errorHandler(app: FastifyInstance) {
}); });
}); });
} }
export const errorHandler = fp(errorHandlerPlugin, { name: "error-handler" });

View File

@ -12,15 +12,22 @@ export async function accountRoutes(app: FastifyInstance) {
// POST /accounts — create a new account (user becomes owner) // POST /accounts — create a new account (user becomes owner)
app.post<{ Body: { name: string } }>( app.post<{ Body: { name: string } }>(
"/accounts", "/accounts",
{ preHandler: [app.authenticate] }, {
preHandler: [app.authenticate],
schema: {
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub;
const { name } = request.body; 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 account = await db.transaction(async (tx) => {
const [created] = await tx.insert(accounts).values({ name }).returning(); const [created] = await tx.insert(accounts).values({ name }).returning();
await tx.insert(memberships).values({ await tx.insert(memberships).values({

View File

@ -3,13 +3,22 @@ import { requestOtp, OtpRateLimitError } from "../lib/otp.js";
export async function loginRoutes(app: FastifyInstance) { export async function loginRoutes(app: FastifyInstance) {
// POST /login — send OTP to email // POST /login — send OTP to email
app.post<{ Body: { email: string } }>("/login", async (request, reply) => { 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; const { email } = request.body;
if (!email || typeof email !== "string") {
return reply.status(400).send({ error: "Email is required" });
}
try { try {
await requestOtp(email); await requestOtp(email);
} catch (err) { } catch (err) {
@ -20,5 +29,6 @@ export async function loginRoutes(app: FastifyInstance) {
} }
return { message: "OTP sent to your email" }; return { message: "OTP sent to your email" };
}); },
);
} }

View File

@ -20,20 +20,27 @@ export async function meRoutes(app: FastifyInstance) {
}); });
// PATCH /me — update current user's profile (authenticated) // PATCH /me — update current user's profile (authenticated)
app.patch<{ Body: { email?: string; name?: string } }>( app.patch<{ Body: { name: string } }>(
"/me", "/me",
{ preHandler: [app.authenticate] }, {
preHandler: [app.authenticate],
schema: {
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub; const userId = request.user.sub;
const { email, name } = request.body; const { name } = request.body;
if (!email && !name) {
return reply.status(400).send({ error: "Nothing to update" });
}
const [user] = await db const [user] = await db
.update(users) .update(users)
.set({ ...(email && { email }), ...(name && { name }), updatedAt: new Date() }) .set({ name, updatedAt: new Date() })
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.returning(); .returning();

View File

@ -8,13 +8,23 @@ import { getUserAccounts } from "../lib/accounts.js";
export async function sessionRoutes(app: FastifyInstance) { export async function sessionRoutes(app: FastifyInstance) {
// POST /sessions — validate OTP, create session for existing user // POST /sessions — validate OTP, create session for existing user
app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => { 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; const { email, code } = request.body;
if (!email || !code) {
return reply.status(400).send({ error: "Email and code are required" });
}
try { try {
await verifyOtp(email, code); await verifyOtp(email, code);
} catch (err) { } catch (err) {
@ -33,16 +43,26 @@ export async function sessionRoutes(app: FastifyInstance) {
const userAccounts = await getUserAccounts(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 // POST /sessions/refresh — rotate refresh token, return new tokens
app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => { 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; const { refreshToken } = request.body;
if (!refreshToken) {
return reply.status(400).send({ error: "Refresh token is required" });
}
const [session] = await db const [session] = await db
.select() .select()
.from(sessions) .from(sessions)
@ -78,7 +98,8 @@ export async function sessionRoutes(app: FastifyInstance) {
const userAccounts = await getUserAccounts(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) // POST /sessions/logout — revoke all sessions (authenticated)
app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => { app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => {

View File

@ -10,15 +10,22 @@ export async function signupRoutes(app: FastifyInstance) {
// POST /signup — validate OTP, create user + account, return tokens // POST /signup — validate OTP, create user + account, return tokens
app.post<{ Body: { email: string; code: string; accountName: string } }>( app.post<{ Body: { email: string; code: string; accountName: string } }>(
"/signup", "/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) => { async (request, reply) => {
const { email, code, accountName } = request.body; const { email, code, accountName } = request.body;
if (!email || !code || !accountName) {
return reply
.status(400)
.send({ error: "Email, code, and accountName are required" });
}
try { try {
await verifyOtp(email, code); await verifyOtp(email, code);
} catch (err) { } catch (err) {

View File

@ -3,6 +3,15 @@ import { config } from "./config.js";
const app = buildApp(); 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) => { app.listen({ port: config.PORT, host: config.HOST }, (err) => {
if (err) { if (err) {
app.log.error(err); app.log.error(err);