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(psql:*)",
"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 { 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);

View File

@ -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 });

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", {
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)],

View File

@ -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<void> {
// 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");
}

View File

@ -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" });

View File

@ -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({

View File

@ -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" };
},
);
}

View File

@ -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();

View File

@ -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) => {

View File

@ -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) {

View File

@ -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);