Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/api/nextjs-backend/bench/dedupe-bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Benchmark: email-uniqueness enforcement β€” O(1) Map index vs naive O(n)
* Array.find scan, across N sequential inserts.
*
* The uplifted /api/users route enforces unique emails with a Map index, so
* each insert stays O(1). A naive `users.find(u => u.email === email)` check
* is O(n) per insert -> O(n^2) across N inserts. This script quantifies the gap.
*
* Run: npx tsx bench/dedupe-bench.ts
*/

interface Rec {
id: number;
email: string;
}

function bench(label: string, fn: () => void): number {
const start = process.hrtime.bigint();
fn();
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1e6;
// eslint-disable-next-line no-console
console.log(`${label.padEnd(28)} ${ms.toFixed(1)} ms`);
return ms;
}

function run(n: number): void {
const emails = Array.from({ length: n }, (_, i) => `user${i}@example.com`);

// eslint-disable-next-line no-console
console.log(`\nN = ${n.toLocaleString()} sequential unique inserts`);

const naiveMs = bench("naive Array.find dedupe", () => {
const users: Rec[] = [];
for (let i = 0; i < n; i += 1) {
const email = emails[i];
if (!users.find((u) => u.email === email)) {
users.push({ id: i, email });
}
}
});

const indexedMs = bench("indexed Map dedupe", () => {
const users: Rec[] = [];
const byEmail = new Map<string, Rec>();
for (let i = 0; i < n; i += 1) {
const email = emails[i];
if (!byEmail.has(email)) {
const rec = { id: i, email };
users.push(rec);
byEmail.set(email, rec);
}
}
});

const speedup = naiveMs / indexedMs;
// eslint-disable-next-line no-console
console.log(`speedup: ${speedup.toFixed(0)}x`);
}

for (const n of [10000, 50000]) {
run(n);
}
40 changes: 17 additions & 23 deletions src/api/nextjs-backend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,17 @@ const nextConfig = {
];
},

// Headers for security and CORS
// Static security headers.
//
// CORS is intentionally NOT set here. A static header block cannot vary
// `Access-Control-Allow-Origin` per request, and emitting the raw
// comma-separated ALLOWED_ORIGINS value produces an invalid ACAO header that
// browsers reject. Origin-reflection CORS lives in src/middleware.ts instead.
async headers() {
return [
{
source: "/api/:path*",
headers: [
{
key: "Access-Control-Allow-Origin",
value:
process.env.NODE_ENV === "production"
? process.env.ALLOWED_ORIGINS || "https://yourdomain.com"
: "*",
},
{
key: "Access-Control-Allow-Methods",
value: "GET, POST, PUT, DELETE, PATCH, OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization, X-Requested-With",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
Expand All @@ -53,20 +43,24 @@ const nextConfig = {
value: "DENY",
},
{
// OWASP guidance: the legacy XSS auditor can introduce
// vulnerabilities; disable it and rely on CSP instead.
key: "X-XSS-Protection",
value: "1; mode=block",
value: "0",
},
{
key: "Referrer-Policy",
value: "no-referrer",
},
],
},
];
},

// Environment variables validation
// Remove NODE_ENV as Next.js handles it automatically
env: {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
},
// NOTE: DATABASE_URL and JWT_SECRET are deliberately NOT exposed via the
// `env` key. Values placed there are inlined into the JavaScript bundle at
// build time (including client bundles) β€” a secret-leak. Server code reads
// them directly from process.env in route handlers / server components.

// Logging configuration
logging: {
Expand Down
103 changes: 101 additions & 2 deletions src/api/nextjs-backend/src/app/api/users/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ const createRequest = (payload: unknown): Request =>
json: async () => payload,
}) as unknown as Request;

const createGetRequest = (query: string): NextRequest =>
({
nextUrl: new URL(`http://localhost/api/users?${query}`),
}) as unknown as NextRequest;

describe("/api/users route handlers", () => {
it("returns an empty user list by default", async () => {
await withFreshModule(async ({ GET }) => {
const response = await GET();
const response = await GET(createGetRequest(""));
expect(response.status).toBe(200);

const body = await response.json();
Expand All @@ -47,7 +52,7 @@ describe("/api/users route handlers", () => {
expect(createBody.data.name).toBe("Test User");
expect(typeof createBody.data.id).toBe("string");

const listResponse = await GET();
const listResponse = await GET(createGetRequest(""));
const listBody = await listResponse.json();
expect(listBody.total).toBe(1);
expect(listBody.data[0].email).toBe("user@example.com");
Expand All @@ -65,4 +70,98 @@ describe("/api/users route handlers", () => {
expect(Array.isArray(body.details)).toBe(true);
});
});

it("rejects a whitespace-only name (plain min(1) would accept it)", async () => {
await withFreshModule(async ({ POST }) => {
const response = await POST(
createRequest({ email: "ws@example.com", name: " " }) as NextRequest
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Validation error");
});
});

it("normalizes email (trim + lowercase) on create", async () => {
await withFreshModule(async ({ POST, GET }) => {
const response = await POST(
createRequest({ email: " MixedCase@Example.COM ", name: "Case User" }) as NextRequest
);
expect(response.status).toBe(201);
const body = await response.json();
expect(body.data.email).toBe("mixedcase@example.com");

const list = await (await GET(createGetRequest(""))).json();
expect(list.data[0].email).toBe("mixedcase@example.com");
});
});

it("rejects a duplicate email with 409 Conflict", async () => {
await withFreshModule(async ({ POST, GET }) => {
const first = await POST(
createRequest({ email: "dupe@example.com", name: "First" }) as NextRequest
);
expect(first.status).toBe(201);

const second = await POST(
createRequest({ email: "dupe@example.com", name: "Second" }) as NextRequest
);
expect(second.status).toBe(409);
const body = await second.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Email already exists");

// No duplicate was stored.
const list = await (await GET(createGetRequest(""))).json();
expect(list.total).toBe(1);
});
});

it("treats differently-cased / padded emails as the same identity", async () => {
await withFreshModule(async ({ POST, GET }) => {
await POST(createRequest({ email: "person@example.com", name: "A" }) as NextRequest);
const dup = await POST(
createRequest({ email: " Person@Example.com ", name: "B" }) as NextRequest
);
expect(dup.status).toBe(409);

const list = await (await GET(createGetRequest(""))).json();
expect(list.total).toBe(1);
});
});

it("paginates with limit/offset while keeping the full total (backward compatible)", async () => {
await withFreshModule(async ({ POST, GET }) => {
for (let i = 0; i < 5; i += 1) {
// eslint-disable-next-line no-await-in-loop -- sequential inserts for deterministic order
await POST(createRequest({ email: `u${i}@example.com`, name: `U${i}` }) as NextRequest);
}

const page = await GET(createGetRequest("limit=2&offset=1"));
expect(page.status).toBe(200);
const body = await page.json();
expect(body.total).toBe(5);
expect(body.data).toHaveLength(2);
expect(body.data[0].email).toBe("u1@example.com");
expect(body.data[1].email).toBe("u2@example.com");
});
});

it("returns the full list unchanged when no pagination params are given", async () => {
await withFreshModule(async ({ POST, GET }) => {
await POST(createRequest({ email: "only@example.com", name: "Only" }) as NextRequest);
const body = await (await GET(createGetRequest(""))).json();
expect(body.data).toHaveLength(1);
expect(body.total).toBe(1);
});
});

it("rejects invalid pagination params with 400", async () => {
await withFreshModule(async ({ GET }) => {
const response = await GET(createGetRequest("limit=-3"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe("Validation error");
});
});
});
72 changes: 67 additions & 5 deletions src/api/nextjs-backend/src/app/api/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,67 @@ import { z } from "zod";
import { User } from "@/types";
import { HttpStatus } from "@/constants";

// In-memory storage for demo
// In-memory storage for demo.
// `users` preserves insertion order for listing; `usersByEmail` is an O(1)
// uniqueness index keyed by the normalized email. Keeping both avoids the
// O(n) `Array.find` scan a naive dedupe would run on every insert (which is
// O(n^2) across n creates).
const users: User[] = [];
const usersByEmail = new Map<string, User>();

// Zod normalizes before validating: trim + lowercase the email so that
// " User@Example.com " and "user@example.com" collide as the same identity,
// and reject whitespace-only names (plain .min(1) accepts " ").
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
email: z.string().trim().toLowerCase().email(),
name: z.string().trim().min(1),
});

export async function GET(): Promise<NextResponse> {
const MAX_PAGE_LIMIT = 1000;

const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(MAX_PAGE_LIMIT).optional(),
offset: z.coerce.number().int().min(0).optional(),
});

// Next.js's route-handler type checker (next build) requires the exported
// GET signature to be assignable to (request: NextRequest) => ... β€” even a
// default-valued (structurally optional) param fails that check. Keep the
// param required; callers (incl. direct unit-test invocation) must pass a
// request. See route.test.ts's createGetRequest("") for the no-params case.
export async function GET(request: NextRequest): Promise<NextResponse> {
const params = request.nextUrl?.searchParams;
const rawLimit = params?.get("limit") ?? undefined;
const rawOffset = params?.get("offset") ?? undefined;

// Backward compatible: with no pagination params, return the full list.
if (rawLimit === undefined && rawOffset === undefined) {
return NextResponse.json({
success: true,
data: users,
total: users.length,
});
}

const parsed = paginationSchema.safeParse({ limit: rawLimit, offset: rawOffset });
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: "Validation error",
details: parsed.error.errors,
},
{ status: HttpStatus.BAD_REQUEST }
);
}

const offset = parsed.data.offset ?? 0;
const limit = parsed.data.limit ?? users.length;
const page = users.slice(offset, offset + limit);

return NextResponse.json({
success: true,
data: users,
data: page,
total: users.length,
});
}
Expand All @@ -35,7 +84,19 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
);
}

// email is already trimmed + lowercased by the schema.
const { email, name } = validatedData.data;

if (usersByEmail.has(email)) {
return NextResponse.json(
{
success: false,
error: "Email already exists",
},
{ status: HttpStatus.CONFLICT }
);
}

const now = new Date();
const newUser: User = {
id: crypto.randomUUID(),
Expand All @@ -46,6 +107,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
};

users.push(newUser);
usersByEmail.set(email, newUser);

return NextResponse.json(
{
Expand Down
Loading
Loading