fix issues and add validation
This commit is contained in:
parent
60f870ece8
commit
127b19b4e1
@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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)],
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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" };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user