diff --git a/app/api/routes-f/__tests__/idempotency.test.ts b/app/api/routes-f/__tests__/idempotency.test.ts new file mode 100644 index 00000000..682986cc --- /dev/null +++ b/app/api/routes-f/__tests__/idempotency.test.ts @@ -0,0 +1,89 @@ +/** + * Routes-F idempotency tests. + */ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), + }, +})); + +import { POST as itemsPost } from "../items/route"; +import { __test__clearIdempotencyStore } from "@/lib/routes-f/idempotency"; +import { __test__setRoutesFRecords } from "@/lib/routes-f/store"; + +const makePostRequest = (body: any, headersInit?: HeadersInit) => { + return new Request(`http://localhost/api/routes-f/items`, { + method: "POST", + headers: headersInit, + body: JSON.stringify(body), + }); +}; + +describe("POST /api/routes-f/items (Idempotency)", () => { + beforeEach(() => { + __test__clearIdempotencyStore(); + __test__setRoutesFRecords([]); + }); + + it("executes handler normally without Idempotency-Key", async () => { + const body = { title: "Test", description: "Desc" }; + const res = await itemsPost(makePostRequest(body)); + expect(res.status).toBe(201); + expect(res.headers.get("x-idempotency-hit")).toBeNull(); + + const res2 = await itemsPost(makePostRequest(body)); + expect(res2.status).toBe(201); + expect(res2.headers.get("x-idempotency-hit")).toBeNull(); + }); + + it("returns cached response for duplicate requests with same key", async () => { + const body = { title: "Test", description: "Desc" }; + const key = "test-key-123"; + const headers = { "idempotency-key": key }; + + const res1 = await itemsPost(makePostRequest(body, headers)); + expect(res1.status).toBe(201); + expect(res1.headers.get("x-idempotency-hit")).toBe("false"); + const data1 = await res1.json(); + + // Second request + const res2 = await itemsPost(makePostRequest(body, headers)); + expect(res2.status).toBe(201); + expect(res2.headers.get("x-idempotency-hit")).toBe("true"); + const data2 = await res2.json(); + + expect(data1).toEqual(data2); + }); + + it("allows different keys to have different results", async () => { + const body = { title: "Test", description: "Desc" }; + + const res1 = await itemsPost(makePostRequest(body, { "idempotency-key": "key-1" })); + expect(res1.headers.get("x-idempotency-hit")).toBe("false"); + + const res2 = await itemsPost(makePostRequest(body, { "idempotency-key": "key-2" })); + expect(res2.headers.get("x-idempotency-hit")).toBe("false"); + }); + + it("handles key expiration", async () => { + jest.useFakeTimers(); + const body = { title: "Test", description: "Desc" }; + const key = "expiring-key"; + const headers = { "idempotency-key": key }; + + const res1 = await itemsPost(makePostRequest(body, headers)); + expect(res1.headers.get("x-idempotency-hit")).toBe("false"); + + // Advance time by 61 seconds + jest.advanceTimersByTime(61 * 1000); + + const res2 = await itemsPost(makePostRequest(body, headers)); + expect(res2.headers.get("x-idempotency-hit")).toBe("false"); + + jest.useRealTimers(); + }); +}); diff --git a/app/api/routes-f/__tests__/mock.test.ts b/app/api/routes-f/__tests__/mock.test.ts new file mode 100644 index 00000000..fb917728 --- /dev/null +++ b/app/api/routes-f/__tests__/mock.test.ts @@ -0,0 +1,110 @@ +/** + * Deterministic Mock Generator Tests + */ +import { generateMockData, DeterministicGenerator } from "@/lib/routes-f/mock-generator"; +import { POST } from "../mock/generate/route"; + +// Mock Next.js Response +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), + }, +})); + +// Mock the logging wrapper as a pass-through +jest.mock("@/lib/routes-f/logging", () => ({ + withRoutesFLogging: jest.fn((req, handler) => handler(req)), +})); + +function makePostRequest(body?: any): Request { + return new Request("http://localhost/api/routes-f/mock/generate", { + method: "POST", + headers: { "content-type": "application/json", "content-length": body ? JSON.stringify(body).length.toString() : "0" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +describe("DeterministicGenerator Utility", () => { + it("generates the same sequence of numbers for the same string seed", () => { + const g1 = new DeterministicGenerator("test-seed-123"); + const g2 = new DeterministicGenerator("test-seed-123"); + + for (let i = 0; i < 100; i++) { + expect(g1.random()).toBe(g2.random()); + } + }); + + it("generates different sequences for different seeds", () => { + const g1 = new DeterministicGenerator("test-seed-123"); + const g2 = new DeterministicGenerator("test-seed-456"); + + // They shouldn't match on the first draw + expect(g1.random()).not.toBe(g2.random()); + }); + + it("generates identical mock data arrays for the same seed", () => { + const data1 = generateMockData("my-seed", 50, "financial"); + const data2 = generateMockData("my-seed", 50, "financial"); + + expect(data1).toEqual(data2); + }); + + it("generates different arrays for different profiles even with same seed", () => { + const data1 = generateMockData("my-seed", 50, "financial"); + const data2 = generateMockData("my-seed", 50, "social"); + + expect(data1).not.toEqual(data2); + }); +}); + +describe("POST /api/routes-f/mock/generate", () => { + it("returns 400 if count exceeds maximum", async () => { + const res = await POST(makePostRequest({ count: 1000 })); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.error).toMatch(/count must be between/); + }); + + it("returns 400 if count is negative", async () => { + const res = await POST(makePostRequest({ count: -5 })); + expect(res.status).toBe(400); + }); + + it("returns 400 if count is not an integer", async () => { + const res = await POST(makePostRequest({ count: 5.5 })); + expect(res.status).toBe(400); + }); + + it("returns default 10 records if no body is provided", async () => { + const res = await POST(new Request("http://localhost/api", { method: "POST" })); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.metadata.count).toBe(10); + expect(data.metadata.seed).toBeDefined(); + expect(data.data.length).toBe(10); + }); + + it("returns deterministic payloads when seeded", async () => { + const seed = "fixed-seed-789"; + const count = 5; + + const res1 = await POST(makePostRequest({ seed, count })); + const res2 = await POST(makePostRequest({ seed, count })); + + const data1 = await res1.json(); + const data2 = await res2.json(); + + expect(data1.metadata.seed).toBe(seed); + expect(data2.metadata.seed).toBe(seed); + + // We stringify and compare to ignore the generatedAt timestamp in metadata + // Data arrays should be strictly identical + expect(data1.data).toEqual(data2.data); + }); +}); diff --git a/app/api/routes-f/__tests__/payload-guard.test.ts b/app/api/routes-f/__tests__/payload-guard.test.ts new file mode 100644 index 00000000..2d5831dc --- /dev/null +++ b/app/api/routes-f/__tests__/payload-guard.test.ts @@ -0,0 +1,119 @@ +/** + * Payload guard utility tests + */ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), + }, +})); + +import { withPayloadGuard, getPayloadLimitBytes } from "@/lib/routes-f/payload-guard"; + +describe("payload-guard getPayloadLimitBytes", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("returns default 50KB when no env and no options", () => { + expect(getPayloadLimitBytes()).toBe(50 * 1024); + }); + + it("respects ROUTES_F_MAX_PAYLOAD_BYTES env var", () => { + process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "1024"; + expect(getPayloadLimitBytes()).toBe(1024); + }); + + it("falls back to default if env var is invalid", () => { + process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "abc"; + expect(getPayloadLimitBytes()).toBe(50 * 1024); + + process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "-10"; + expect(getPayloadLimitBytes()).toBe(50 * 1024); + }); + + it("overrides env var when maxBytes option is provided", () => { + process.env.ROUTES_F_MAX_PAYLOAD_BYTES = "1024"; + expect(getPayloadLimitBytes({ maxBytes: 2048 })).toBe(2048); + }); +}); + +describe("withPayloadGuard middleware", () => { + const mockHandler = jest.fn().mockImplementation((req) => new Response("OK", { status: 200 })); + + beforeEach(() => { + mockHandler.mockClear(); + }); + + it("allows requests under the limit", async () => { + const req = new Request("http://localhost/api", { + method: "POST", + headers: { "content-length": "100" }, + body: "test", + }); + + const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 }); + expect(res.status).toBe(200); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it("rejects requests exactly equal to limit", async () => { + // Actually, it usually allows exact limits (`> limitBytes`), so let's verify that. + const req = new Request("http://localhost/api", { + method: "POST", + headers: { "content-length": "200" }, + body: "test", + }); + + const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 }); + expect(res.status).toBe(200); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it("rejects requests strictly over the limit with 413", async () => { + const req = new Request("http://localhost/api", { + method: "POST", + headers: { "content-length": "201" }, + body: "test test test test test", + }); + + const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 }); + expect(res.status).toBe(413); + + const data = await res.json(); + expect(data.error).toBe("Payload too large"); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it("allows requests missing content-length header", async () => { + const req = new Request("http://localhost/api", { + method: "GET", // GET usually has no content-length + }); + + const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 }); + expect(res.status).toBe(200); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it("allows requests with unparsable content-length header", async () => { + const req = new Request("http://localhost/api", { + method: "POST", + headers: { "content-length": "chunked" }, + body: "test", + }); + + const res = await withPayloadGuard(req, mockHandler, { maxBytes: 200 }); + expect(res.status).toBe(200); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/api/routes-f/import/route.ts b/app/api/routes-f/import/route.ts index cd672174..8c963d56 100644 --- a/app/api/routes-f/import/route.ts +++ b/app/api/routes-f/import/route.ts @@ -1,76 +1,78 @@ import { NextResponse } from "next/server"; import { validateRoutesFRecord } from "@/lib/routes-f/schema"; import { withRoutesFLogging } from "@/lib/routes-f/logging"; +import { withIdempotency } from "@/lib/routes-f/idempotency"; +import { withPayloadGuard } from "@/lib/routes-f/payload-guard"; const MAX_RECORDS = 100; const MAX_PAYLOAD_BYTES = 100 * 1024; export async function POST(req: Request) { - return withRoutesFLogging(req, async request => { - const contentLength = request.headers.get("content-length"); - if (contentLength && Number(contentLength) > MAX_PAYLOAD_BYTES) { - return NextResponse.json( - { error: "Payload too large" }, - { status: 413 } - ); - } + return withPayloadGuard( + req, + async (requestWithGuard) => { + return withIdempotency(requestWithGuard, async (request) => { + return withRoutesFLogging(request, async reqWithId => { + let body: unknown; - let body: unknown; + try { + body = await reqWithId.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON payload" }, + { status: 400 } + ); + } - try { - body = await request.json(); - } catch { - return NextResponse.json( - { error: "Invalid JSON payload" }, - { status: 400 } - ); - } + if (!Array.isArray(body)) { + return NextResponse.json( + { error: "Payload must be an array of records" }, + { status: 400 } + ); + } - if (!Array.isArray(body)) { - return NextResponse.json( - { error: "Payload must be an array of records" }, - { status: 400 } - ); - } + if (body.length > MAX_RECORDS) { + return NextResponse.json( + { error: `Too many records. Max is ${MAX_RECORDS}` }, + { status: 400 } + ); + } - if (body.length > MAX_RECORDS) { - return NextResponse.json( - { error: `Too many records. Max is ${MAX_RECORDS}` }, - { status: 400 } - ); - } + const results = body.map((item, index) => { + const validation = validateRoutesFRecord(item); + return { + index, + ok: validation.isValid, + errors: validation.errors, + warnings: validation.warnings, + }; + }); - const results = body.map((item, index) => { - const validation = validateRoutesFRecord(item); - return { - index, - ok: validation.isValid, - errors: validation.errors, - warnings: validation.warnings, - }; - }); + const validCount = results.filter(result => result.ok).length; + const invalidCount = results.length - validCount; - const validCount = results.filter(result => result.ok).length; - const invalidCount = results.length - validCount; + const responsePayload = { + imported: validCount, + failed: invalidCount, + results, + message: + invalidCount === 0 + ? "Import simulated successfully" + : "Import completed with validation errors", + }; - const responsePayload = { - imported: validCount, - failed: invalidCount, - results, - message: - invalidCount === 0 - ? "Import simulated successfully" - : "Import completed with validation errors", - }; + if (validCount > 0 && invalidCount > 0) { + return NextResponse.json(responsePayload, { status: 207 }); + } - if (validCount > 0 && invalidCount > 0) { - return NextResponse.json(responsePayload, { status: 207 }); - } + if (validCount === 0) { + return NextResponse.json(responsePayload, { status: 422 }); + } - if (validCount === 0) { - return NextResponse.json(responsePayload, { status: 422 }); - } - - return NextResponse.json(responsePayload, { status: 200 }); - }); + return NextResponse.json(responsePayload, { status: 200 }); + }); + }); + }, + { maxBytes: MAX_PAYLOAD_BYTES } + ); } diff --git a/app/api/routes-f/items/route.ts b/app/api/routes-f/items/route.ts index a15a9e7b..b6eeb6fd 100644 --- a/app/api/routes-f/items/route.ts +++ b/app/api/routes-f/items/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { listRoutesFRecords, createRoutesFRecord } from "@/lib/routes-f/store"; +import { withIdempotency } from "@/lib/routes-f/idempotency"; +import { withPayloadGuard } from "@/lib/routes-f/payload-guard"; /** * GET /api/routes-f @@ -33,50 +35,54 @@ export async function GET(req: Request) { * Create a new record */ export async function POST(req: Request) { - let body; + return withPayloadGuard(req, async (requestWithGuard) => { + return withIdempotency(requestWithGuard, async (request) => { + let body; - try { - body = await req.json(); - } catch { - return NextResponse.json( - { error: "Invalid JSON" }, - { status: 400 } - ); - } + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON" }, + { status: 400 } + ); + } - try { - const newRecord = createRoutesFRecord({ - title: body.title, - description: body.description, - tags: body.tags, - }); + try { + const newRecord = createRoutesFRecord({ + title: body.title, + description: body.description, + tags: body.tags, + }); - const location = new URL( - `/api/routes-f/items/${newRecord.id}`, - req.url - ).toString(); + const location = new URL( + `/api/routes-f/items/${newRecord.id}`, + request.url + ).toString(); - const headers = new Headers(); - headers.set("Location", location); + const headers = new Headers(); + headers.set("Location", location); - return NextResponse.json(newRecord, { - status: 201, - headers, - }); - } catch (error: any) { - if (error?.message === "invalid-payload") { - return NextResponse.json( - { - error: "Unprocessable Entity", - message: "Missing title or description", - }, - { status: 422 } - ); - } + return NextResponse.json(newRecord, { + status: 201, + headers, + }); + } catch (error: any) { + if (error?.message === "invalid-payload") { + return NextResponse.json( + { + error: "Unprocessable Entity", + message: "Missing title or description", + }, + { status: 422 } + ); + } - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } + }); + }); } \ No newline at end of file diff --git a/app/api/routes-f/mock/generate/route.ts b/app/api/routes-f/mock/generate/route.ts new file mode 100644 index 00000000..90907fcd --- /dev/null +++ b/app/api/routes-f/mock/generate/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { withRoutesFLogging } from "@/lib/routes-f/logging"; +import { withPayloadGuard } from "@/lib/routes-f/payload-guard"; +import { generateMockData } from "@/lib/routes-f/mock-generator"; + +const MAX_COUNT = 500; +const MIN_COUNT = 1; +const DEFAULT_COUNT = 10; +const MAX_PAYLOAD_BYTES = 50 * 1024; // 50KB + +export async function POST(req: Request) { + return withPayloadGuard( + req, + async (requestWithGuard) => { + return withRoutesFLogging(requestWithGuard, async reqWithId => { + let body: any = {}; + + try { + // Read body if present. It might be empty, which is fine since defaults exist. + const text = await reqWithId.text(); + if (text) { + body = JSON.parse(text); + } + } catch { + return NextResponse.json( + { error: "Invalid JSON payload" }, + { status: 400 } + ); + } + + let seed = body.seed; + if (seed === undefined || seed === null) { + // Generate a random seed if none provided. + // The generated data will still be deterministic for *this* generated seed. + seed = Math.random().toString(36).substring(2, 10); + } + + let count = body.count !== undefined ? Number(body.count) : DEFAULT_COUNT; + + if (isNaN(count) || !Number.isInteger(count)) { + return NextResponse.json( + { error: "count must be an integer" }, + { status: 400 } + ); + } + + if (count < MIN_COUNT || count > MAX_COUNT) { + return NextResponse.json( + { error: `count must be between ${MIN_COUNT} and ${MAX_COUNT}` }, + { status: 400 } + ); + } + + const profileType = body.profileType !== undefined ? String(body.profileType) : "default"; + + const data = generateMockData(seed, count, profileType); + + return NextResponse.json({ + metadata: { + seed, + count, + profileType, + generatedAt: new Date().toISOString(), + }, + data + }, { status: 200 }); + }); + }, + { maxBytes: MAX_PAYLOAD_BYTES } // Apply guard directly on this route + ); +} diff --git a/lib/routes-f/idempotency.ts b/lib/routes-f/idempotency.ts new file mode 100644 index 00000000..54c2792d --- /dev/null +++ b/lib/routes-f/idempotency.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; + +interface IdempotentResponse { + body: string; + status: number; + headers: [string, string][]; + expiresAt: number; +} + +const idempotencyStore = new Map(); +const DEFAULT_IDEMPOTENCY_TTL_MS = 60 * 1000; // 60 seconds + +const IDEMPOTENCY_HEADER = "idempotency-key"; +const IDEMPOTENCY_HIT_HEADER = "x-idempotency-hit"; + +export async function withIdempotency( + req: Request, + handler: (request: Request) => Promise +): Promise { + const key = req.headers.get(IDEMPOTENCY_HEADER); + + if (!key) { + return handler(req); + } + + const now = Date.now(); + const cached = idempotencyStore.get(key); + + if (cached) { + if (now < cached.expiresAt) { + const headers = new Headers(cached.headers); + headers.set(IDEMPOTENCY_HIT_HEADER, "true"); + return new Response(cached.body, { + status: cached.status, + headers, + }); + } else { + idempotencyStore.delete(key); + } + } + + // Clone request to avoid "body already used" if the handler reads it + // and we also need it for caching (though we mainly cache the response) + const response = await handler(req); + + // Consider only caching successful or client-error responses? + // Usually, we cache all except 5xx errors to be safe. + if (response.status < 500) { + const responseClone = response.clone(); + const body = await responseClone.text(); + const headers: [string, string][] = []; + responseClone.headers.forEach((value, name) => { + // Don't cache transient headers if any + headers.push([name, value]); + }); + + idempotencyStore.set(key, { + body, + status: response.status, + headers, + expiresAt: now + DEFAULT_IDEMPOTENCY_TTL_MS, + }); + } + + const finalHeaders = new Headers(response.headers); + finalHeaders.set(IDEMPOTENCY_HIT_HEADER, "false"); + + // We can't re-use the original response because we consumed its body with text() in the clone path + // Wait, no, we used responseClone.text(). So response is still usable? + // Actually, it's safer to reconstruct if we already have the body. + // But wait, if we didn't cache (>= 500), we just return response. + + if (response.status >= 500) { + return response; + } + + // To ensure headers are updated with IDEMPOTENCY_HIT_HEADER + const cachedData = idempotencyStore.get(key); + if (cachedData) { + const hs = new Headers(cachedData.headers); + hs.set(IDEMPOTENCY_HIT_HEADER, "false"); + return new Response(cachedData.body, { + status: cachedData.status, + headers: hs, + }); + } + + return response; +} + +export function __test__clearIdempotencyStore() { + idempotencyStore.clear(); +} diff --git a/lib/routes-f/mock-generator.ts b/lib/routes-f/mock-generator.ts new file mode 100644 index 00000000..08cbd3e9 --- /dev/null +++ b/lib/routes-f/mock-generator.ts @@ -0,0 +1,127 @@ +import { RoutesFRecord, RoutesFMethod } from "./schema"; + +// Mulberry32 PRNG +// https://github.com/bryc/code/blob/master/jshash/PRNGs.md +function mulberry32(a: number) { + return function () { + let t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } +} + +// Simple string hashing function to generate a numeric seed from a string +function cyrb128(str: string): number { + let h1 = 1779033703, h2 = 3144134277, + h3 = 1013904242, h4 = 2773480762; + for (let i = 0, k; i < str.length; i++) { + k = str.charCodeAt(i); + h1 = h2 ^ Math.imul(h1 ^ k, 597399067); + h2 = h3 ^ Math.imul(h2 ^ k, 2869860233); + h3 = h4 ^ Math.imul(h3 ^ k, 951274213); + h4 = h1 ^ Math.imul(h4 ^ k, 2716044179); + } + h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067); + h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233); + h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213); + h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179); + return (h1 ^ h2 ^ h3 ^ h4) >>> 0; +} + +export class DeterministicGenerator { + private prng: () => number; + + constructor(seed: string | number) { + const numericSeed = typeof seed === "string" ? cyrb128(seed) : seed; + this.prng = mulberry32(numericSeed); + } + + random(): number { + return this.prng(); + } + + randomInt(min: number, max: number): number { + return Math.floor(this.random() * (max - min + 1)) + min; + } + + pick(items: T[]): T { + return items[this.randomInt(0, items.length - 1)]; + } + + pickN(items: T[], n: number): T[] { + const shuffled = [...items].sort(() => 0.5 - this.random()); + return shuffled.slice(0, n); + } + + generateId(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "rf-mock-"; + for (let i = 0; i < 8; i++) { + id += chars[this.randomInt(0, chars.length - 1)]; + } + return id; + } +} + +const DOMAINS: Record = { + financial: { + paths: ["/transactions", "/balance", "/invoices", "/portfolio"], + verbs: ["Process", "Calculate", "Audit", "Sync"], + prefixes: ["Ledger", "Account", "Asset", "Tax"], + tags: ["finance", "critical", "billing", "audit"], + }, + social: { + paths: ["/feed", "/friends", "/messages", "/notifications"], + verbs: ["Fetch", "Update", "Broadcast", "Like"], + prefixes: ["User", "Post", "Comment", "Reaction"], + tags: ["engagement", "public", "realtime", "cacheable"], + }, + default: { + paths: ["/items", "/records", "/data", "/config"], + verbs: ["Get", "Set", "Load", "Save"], + prefixes: ["App", "System", "Base", "Core"], + tags: ["general", "system", "internal"], + } +}; + +const METHODS: RoutesFMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +export function generateMockData(seed: string | number, count: number, profileType: string = "default"): Omit[] { + const generator = new DeterministicGenerator(seed); + const domain = DOMAINS[profileType] || DOMAINS["default"]; + + const records: Omit[] = []; + + for (let i = 0; i < count; i++) { + const method = generator.pick(METHODS); + const pathBase = generator.pick(domain.paths); + const hasIdParam = generator.random() > 0.5; + const path = hasIdParam ? `${pathBase}/:id` : pathBase; + + const prefix = generator.pick(domain.prefixes); + const verb = generator.pick(domain.verbs); + const name = `${verb} ${prefix} ${i}`; + + const tags = generator.pickN(domain.tags, generator.randomInt(1, 3)); + + const priority = generator.randomInt(0, 100); + const enabled = generator.random() > 0.2; // 80% chance of being enabled + + records.push({ + name, + path, + method, + priority, + enabled, + tags, + metadata: { + generatedAt: new Date(1704067200000 + generator.randomInt(0, 31536000000)).toISOString(), // Sometime in 2024 + source: "mock-generator", + profile: profileType + } + }); + } + + return records; +} diff --git a/lib/routes-f/payload-guard.ts b/lib/routes-f/payload-guard.ts new file mode 100644 index 00000000..5190ea6d --- /dev/null +++ b/lib/routes-f/payload-guard.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_MAX_PAYLOAD_BYTES = 50 * 1024; // 50KB default + +export interface PayloadGuardOptions { + maxBytes?: number; +} + +export function getPayloadLimitBytes(options?: PayloadGuardOptions): number { + if (options?.maxBytes !== undefined) { + return options.maxBytes; + } + + const envValue = process.env.ROUTES_F_MAX_PAYLOAD_BYTES; + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } + + return DEFAULT_MAX_PAYLOAD_BYTES; +} + +export async function withPayloadGuard( + req: Request, + handler: (request: Request) => Promise, + options?: PayloadGuardOptions +): Promise { + const limitBytes = getPayloadLimitBytes(options); + const contentLengthAttr = req.headers.get("content-length"); + + if (contentLengthAttr) { + const contentLength = parseInt(contentLengthAttr, 10); + + // If we can parse it and it explicitly exceeds + if (!isNaN(contentLength) && contentLength > limitBytes) { + return NextResponse.json( + { + error: "Payload too large", + details: `Request body must not exceed ${limitBytes} bytes` + }, + { status: 413 } + ); + } + } + + // If content-length is missing, we could technically still get a large payload. + // We'd have to read a stream and fail if it goes over, but returning 413 statically + // via content-length is the requirement. Real body limits are often handled by Next.js configs. + // Next.js body parser has its own limits, but this enforces our strict custom guard. + + return handler(req); +}