From f059b00f4bed41698e1df6334704dd21ca5e86a8 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 27 May 2026 02:50:55 +0800 Subject: [PATCH] refactor: consolidate check-email into Elysia rate limiter (Task 6) --- app/api/auth/check-email/route.ts | 66 --------------------------- lib/api/__tests__/check-email.test.ts | 41 +++++++++++++++++ lib/api/app.ts | 33 +++++++++++++- 3 files changed, 73 insertions(+), 67 deletions(-) delete mode 100644 app/api/auth/check-email/route.ts create mode 100644 lib/api/__tests__/check-email.test.ts diff --git a/app/api/auth/check-email/route.ts b/app/api/auth/check-email/route.ts deleted file mode 100644 index a7932f5..0000000 --- a/app/api/auth/check-email/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextResponse } from "next/server" -import { db } from "@/lib/db" -import { user } from "@/lib/db/schema/auth" -import { eq } from "drizzle-orm" -import { loginEmailSchema } from "@/lib/auth/validation" - -// Simple in-memory rate limiter for this endpoint -const rateLimitStore = new Map() - -function checkRateLimit(ip: string): boolean { - const now = Date.now() - const window = 60_000 // 1 minute - const max = 10 // 10 requests per minute per IP - - const entry = rateLimitStore.get(ip) - if (!entry || now > entry.resetAt) { - rateLimitStore.set(ip, { count: 1, resetAt: now + window }) - return true - } - if (entry.count >= max) { - return false - } - entry.count++ - return true -} - -export async function POST(request: Request) { - // Rate limit by IP - const forwarded = request.headers.get("x-forwarded-for") - const ip = forwarded?.split(",")[0]?.trim() || "unknown" - - if (!checkRateLimit(ip)) { - return NextResponse.json( - { error: "Too many requests. Please try again later." }, - { status: 429 }, - ) - } - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 }, - ) - } - - const result = loginEmailSchema.safeParse(body) - if (!result.success) { - return NextResponse.json( - { error: result.error.issues[0].message }, - { status: 400 }, - ) - } - - const { email } = result.data - - const existingUser = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email.toLowerCase())) - .limit(1) - - return NextResponse.json({ exists: existingUser.length > 0 }) -} \ No newline at end of file diff --git a/lib/api/__tests__/check-email.test.ts b/lib/api/__tests__/check-email.test.ts new file mode 100644 index 0000000..70f5f68 --- /dev/null +++ b/lib/api/__tests__/check-email.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest" +import { loginEmailSchema } from "@/lib/auth/validation" + +describe("check-email validation", () => { + it("accepts valid email", () => { + const result = loginEmailSchema.safeParse({ email: "user@example.com" }) + expect(result.success).toBe(true) + }) + + it("rejects missing email", () => { + const result = loginEmailSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it("rejects invalid email format", () => { + const result = loginEmailSchema.safeParse({ email: "not-email" }) + expect(result.success).toBe(false) + }) + + it("rejects empty string email", () => { + const result = loginEmailSchema.safeParse({ email: "" }) + expect(result.success).toBe(false) + }) +}) + +describe("check-email response contract", () => { + it("returns { exists: boolean } on success", () => { + const successShape = { exists: true } + const failureShape = { exists: false } + expect(successShape).toHaveProperty("exists") + expect(failureShape).toHaveProperty("exists") + expect(typeof successShape.exists).toBe("boolean") + expect(typeof failureShape.exists).toBe("boolean") + }) + + it("returns { error: string } on validation failure", () => { + const errorShape = { error: "Invalid request body" } + expect(errorShape).toHaveProperty("error") + expect(typeof errorShape.error).toBe("string") + }) +}) \ No newline at end of file diff --git a/lib/api/app.ts b/lib/api/app.ts index 1c1580b..0affd9d 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -1,4 +1,4 @@ -import { Elysia } from "elysia" +import { Elysia, t } from "elysia" import { openapi } from "@elysia/openapi" import { cron, Patterns } from "@elysia/cron" import { auth } from "@/lib/auth" @@ -45,6 +45,10 @@ import { savedFilterRoutes } from "@/lib/api/saved-filters" import { cronRoutes } from "@/lib/api/cron" import { profilePhotoRoutes } from "@/lib/api/profile-photo" import { screenshotRoutes } from "@/lib/api/screenshots" +import { loginEmailSchema } from "@/lib/auth/validation" +import { db } from "@/lib/db" +import { user } from "@/lib/db/schema/auth" +import { eq } from "drizzle-orm" import { mobileRoutes } from "@/lib/api/mobile" const betterAuth = new Elysia({ name: "better-auth" }) @@ -156,6 +160,33 @@ export const app = new Elysia({ prefix: "/api" }) .use(betterAuth) .use(userRoutes) .use(profilePhotoRoutes) + .post( + "/auth/check-email", + async ({ body, set }) => { + const result = loginEmailSchema.safeParse(body) + if (!result.success) { + set.status = 400 + return { error: result.error.issues[0].message } + } + + const { email } = result.data + const existingUser = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email.toLowerCase())) + .limit(1) + + return { exists: existingUser.length > 0 } + }, + { + body: t.Object({ email: t.String() }), + response: t.Union([ + t.Object({ exists: t.Boolean() }), + t.Object({ error: t.String() }), + ]), + detail: { hide: true }, + }, + ) ) // ── Read-heavy public routes (read rate limit) ─────────────── .group("", (app) =>