From 95d8f10ba7b97c98dbd703622c4dd517be583a6a Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 4 Feb 2026 21:44:58 +1300 Subject: [PATCH] fix: enforce fal.ai API key requirement and update error handling - Added validation to ensure fal.ai API key is provided in requests, returning a 401 status with an appropriate error message if not configured. - Updated related tests to reflect the new API key requirement and adjusted assertions for expected responses. - Modified documentation to clarify that fal.ai API key is now mandatory for model access. --- src/app/api/generate/__tests__/route.test.ts | 57 +---- src/app/api/generate/route.ts | 10 + src/app/api/models/[modelId]/route.ts | 9 + src/app/api/models/__tests__/route.test.ts | 15 +- src/app/api/models/route.ts | 12 +- src/app/api/providers/fal/models/route.ts | 15 +- src/components/CubicBezierEditor.tsx | 62 +++-- .../__tests__/ConnectionDropMenu.test.tsx | 7 +- .../__tests__/VideoStitchNode.test.tsx | 226 ++++++++++++++++++ 9 files changed, 329 insertions(+), 84 deletions(-) create mode 100644 src/components/__tests__/VideoStitchNode.test.tsx diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index dece6408..ec8fd9e8 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -878,7 +878,7 @@ describe("/api/generate route", () => { beforeEach(() => { global.fetch = mockFetch; - mockFetch.mockClear(); + mockFetch.mockReset(); }); afterEach(() => { @@ -1617,7 +1617,7 @@ describe("/api/generate route", () => { beforeEach(() => { global.fetch = mockFetch; - mockFetch.mockClear(); + mockFetch.mockReset(); }); afterEach(() => { @@ -1720,30 +1720,9 @@ describe("/api/generate route", () => { expect(data.contentType).toBe("video"); }); - it("should work without API key (fal.ai allows unauthenticated)", async () => { + it("should return 401 without API key", async () => { delete process.env.FAL_API_KEY; - // Schema fetch (for input mapping when no dynamicInputs) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ models: [] }), - }); - - // fal.run API call - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ - images: [{ url: "https://fal.media/output.png" }], - }), - }); - - // Fetch output media - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "image/png" }), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)), - }); - const request = createMockPostRequest({ prompt: "A beautiful landscape", selectedModel: { @@ -1756,12 +1735,9 @@ describe("/api/generate route", () => { const response = await POST(request); const data = await response.json(); - expect(response.status).toBe(200); - expect(data.success).toBe(true); - - // Verify fal.run request was made without Authorization header (2nd call) - const falRunCall = mockFetch.mock.calls[1]; - expect(falRunCall[1].headers).not.toHaveProperty("Authorization"); + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.error).toContain("API key not configured"); }); it("should handle rate limit (429) with API key", async () => { @@ -1799,22 +1775,9 @@ describe("/api/generate route", () => { expect(data.error).toContain("Try again in a moment"); }); - it("should handle rate limit (429) without API key", async () => { + it("should return 401 for rate limit scenario without API key", async () => { delete process.env.FAL_API_KEY; - // Schema fetch (for input mapping when no dynamicInputs) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ models: [] }), - }); - - // fal.run returns 429 - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 429, - text: () => Promise.resolve(JSON.stringify({ detail: "Rate limit exceeded" })), - }); - const request = createMockPostRequest({ prompt: "Test prompt", selectedModel: { @@ -1827,10 +1790,10 @@ describe("/api/generate route", () => { const response = await POST(request); const data = await response.json(); - expect(response.status).toBe(500); + // Without API key, route returns 401 before reaching fal.ai + expect(response.status).toBe(401); expect(data.success).toBe(false); - expect(data.error).toContain("Rate limit exceeded"); - expect(data.error).toContain("Add an API key"); + expect(data.error).toContain("API key not configured"); }); it("should handle image object response format", async () => { diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index fbc11af3..cdfea8d6 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1394,6 +1394,16 @@ export async function POST(request: NextRequest) { // User-provided key takes precedence over env variable const falApiKey = request.headers.get("X-Fal-API-Key") || process.env.FAL_API_KEY || null; + if (!falApiKey) { + return NextResponse.json( + { + success: false, + error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.", + }, + { status: 401 } + ); + } + // For fal.ai, keep Data URIs as-is since localhost URLs won't work // fal.ai accepts Data URIs directly const processedImages: string[] = images ? [...images] : []; diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index ca08c6ce..7fb56b9b 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -529,6 +529,15 @@ export async function GET( } else { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.", + }, + { status: 401 } + ); + } result = await fetchFalSchema(decodedModelId, apiKey); } diff --git a/src/app/api/models/__tests__/route.test.ts b/src/app/api/models/__tests__/route.test.ts index 35728bae..24197c1a 100644 --- a/src/app/api/models/__tests__/route.test.ts +++ b/src/app/api/models/__tests__/route.test.ts @@ -90,6 +90,8 @@ function createFalResponse(models: Array<{ id: string; name: string; category: s describe("/api/models route", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset fetch mock fully (clears mockResolvedValueOnce queue to prevent leaks) + mockFetch.mockReset(); // Reset env to original process.env = { ...originalEnv }; // Clear API keys @@ -108,7 +110,7 @@ describe("/api/models route", () => { describe("basic functionality", () => { it("GET: should return models from fal.ai when no Replicate key", async () => { - // fal.ai works without key + process.env.FAL_API_KEY = "test-fal-key"; mockFetch.mockResolvedValueOnce( createFalResponse([ { id: "fal-ai/flux", name: "Flux", category: "text-to-image" }, @@ -256,6 +258,7 @@ describe("/api/models route", () => { it("GET: should return cached=true when all from cache", async () => { process.env.REPLICATE_API_KEY = "test-replicate-key"; + process.env.FAL_API_KEY = "test-fal-key"; // Set up cache hits for both providers mockGetCachedModels.mockImplementation((key: string) => { @@ -282,6 +285,7 @@ describe("/api/models route", () => { }); it("GET: should return cached=false when fresh fetch", async () => { + process.env.FAL_API_KEY = "test-fal-key"; // No cache hits mockGetCachedModels.mockReturnValue(null); @@ -416,6 +420,7 @@ describe("/api/models route", () => { describe("error handling", () => { it("GET: should handle partial provider failures gracefully", async () => { process.env.REPLICATE_API_KEY = "test-key"; + process.env.FAL_API_KEY = "test-fal-key"; // Mock fetch to handle both providers mockFetch.mockImplementation((url: string) => { @@ -449,6 +454,7 @@ describe("/api/models route", () => { }); it("GET: should return 500 when all requested providers fail", async () => { + process.env.FAL_API_KEY = "test-fal-key"; // Filter to only fal provider (exclude gemini which is always available) // When fal fails and it's the only provider requested, should get 500 mockFetch.mockImplementation((url: string) => { @@ -472,6 +478,7 @@ describe("/api/models route", () => { describe("pagination", () => { it("GET: should paginate through Replicate results (max 15 pages)", async () => { process.env.REPLICATE_API_KEY = "test-key"; + process.env.FAL_API_KEY = "test-fal-key"; // Track Replicate page fetches let replicatePageCount = 0; @@ -523,7 +530,7 @@ describe("/api/models route", () => { }); it("GET: should paginate through fal.ai results (max 15 pages)", async () => { - // Only fal.ai (no Replicate key) + process.env.FAL_API_KEY = "test-fal-key"; let falPageCount = 0; mockFetch.mockImplementation((url: string) => { @@ -657,7 +664,7 @@ describe("/api/models route", () => { describe("fal.ai category mapping", () => { it("GET: should map fal.ai categories to ModelCapability", async () => { - // Filter to only fal provider so we can test without Replicate + process.env.FAL_API_KEY = "test-fal-key"; mockFetch.mockImplementation((url: string) => { if (url.includes("fal.ai")) { return Promise.resolve( @@ -686,6 +693,7 @@ describe("/api/models route", () => { }); it("GET: should filter out non-relevant fal.ai categories", async () => { + process.env.FAL_API_KEY = "test-fal-key"; mockFetch.mockImplementation((url: string) => { if (url.includes("fal.ai")) { return Promise.resolve( @@ -713,6 +721,7 @@ describe("/api/models route", () => { describe("sorting", () => { it("GET: should sort models by provider, then by name", async () => { process.env.REPLICATE_API_KEY = "test-key"; + process.env.FAL_API_KEY = "test-fal-key"; mockFetch.mockImplementation((url: string) => { if (url.includes("replicate.com")) { diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index b3840d48..929b5587 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -13,7 +13,7 @@ * * Headers: * - X-Replicate-Key: Replicate API key - * - X-Fal-Key: fal.ai API key (optional, works without but rate limited) + * - X-Fal-Key: fal.ai API key (required for fal.ai models) * * Response: * { @@ -357,18 +357,18 @@ export async function GET( includeGemini = true; } else if (providerFilter === "replicate" && replicateKey) { providersToFetch.push("replicate"); - } else if (providerFilter === "fal") { - // fal.ai works without key + } else if (providerFilter === "fal" && falKey) { providersToFetch.push("fal"); } } else { - // Include all providers + // Include all providers that have keys configured includeGemini = true; // Gemini always available if (replicateKey) { providersToFetch.push("replicate"); } - // fal.ai always included (works without key) - providersToFetch.push("fal"); + if (falKey) { + providersToFetch.push("fal"); + } } // Gemini is always available, so we don't fail if no external providers diff --git a/src/app/api/providers/fal/models/route.ts b/src/app/api/providers/fal/models/route.ts index 5e7836e9..cbe25bd7 100644 --- a/src/app/api/providers/fal/models/route.ts +++ b/src/app/api/providers/fal/models/route.ts @@ -108,10 +108,21 @@ type ModelsResponse = ModelsSuccessResponse | ModelsErrorResponse; export async function GET( request: NextRequest ): Promise> { - // Get optional API key from header only (never from query params to avoid credential leakage) + // Get API key from header or env (never from query params to avoid credential leakage) const apiKey = request.headers.get("X-API-Key") || - request.headers.get("Authorization")?.replace(/^Key\s+/i, ""); + request.headers.get("Authorization")?.replace(/^Key\s+/i, "") || + process.env.FAL_API_KEY; + + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.", + }, + { status: 401 } + ); + } const searchQuery = request.nextUrl.searchParams.get("search"); diff --git a/src/components/CubicBezierEditor.tsx b/src/components/CubicBezierEditor.tsx index 56b7ecbf..027b0306 100644 --- a/src/components/CubicBezierEditor.tsx +++ b/src/components/CubicBezierEditor.tsx @@ -1,22 +1,24 @@ "use client"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface CubicBezierEditorProps { value: [number, number, number, number]; onChange: (value: [number, number, number, number]) => void; onCommit?: (value: [number, number, number, number]) => void; disabled?: boolean; + /** SVG polyline points string (in 0–100 coordinate space) for the actual easing curve overlay */ + easingCurve?: string; } const clamp = (value: number) => Math.max(0, Math.min(1, value)); -// Hardcoded dark-theme palette (node-banana is dark mode only) +// Hardcoded dark-theme palette matching easy-peasy-ease color scheme const palette = { - background: "#171717", // neutral-900 + background: "#0f1720", // dark navy (easy-peasy-ease bg tint) border: "rgba(255,255,255,0.12)", muted: "rgba(255,255,255,0.35)", - primary: "#f59e0b", // amber-500 + primary: "#bef264", // lime-300 (easy-peasy-ease primary) }; const now = () => @@ -24,11 +26,12 @@ const now = () => ? performance.now() : Date.now(); -function CubicBezierEditorComponent({ +export function CubicBezierEditor({ value, onChange, onCommit, disabled = false, + easingCurve, }: CubicBezierEditorProps) { const editorRef = useRef(null); const valueRef = useRef(value); @@ -40,9 +43,12 @@ function CubicBezierEditorComponent({ capturedAt: number; } | null>(null); + const [v0, v1, v2, v3] = value; + + // Sync valueRef with prop values (used by drag handlers) useEffect(() => { valueRef.current = value; - }, [value]); + }, [v0, v1, v2, v3]); // eslint-disable-line react-hooks/exhaustive-deps const flushPendingChange = useCallback(() => { commitFrameRef.current = null; @@ -132,25 +138,25 @@ function CubicBezierEditorComponent({ const controlStyles = useMemo( () => ({ p1: { - left: `${(value[0] * 100).toFixed(2)}%`, - top: `${((1 - value[1]) * 100).toFixed(2)}%`, + left: `${(v0 * 100).toFixed(2)}%`, + top: `${((1 - v1) * 100).toFixed(2)}%`, }, p2: { - left: `${(value[2] * 100).toFixed(2)}%`, - top: `${((1 - value[3]) * 100).toFixed(2)}%`, + left: `${(v2 * 100).toFixed(2)}%`, + top: `${((1 - v3) * 100).toFixed(2)}%`, }, }), - [value] + [v0, v1, v2, v3] ); const svgPoints = useMemo( () => ({ start: { x: 0, y: 100 }, end: { x: 100, y: 0 }, - c1: { x: value[0] * 100, y: (1 - value[1]) * 100 }, - c2: { x: value[2] * 100, y: (1 - value[3]) * 100 }, + c1: { x: v0 * 100, y: (1 - v1) * 100 }, + c2: { x: v2 * 100, y: (1 - v3) * 100 }, }), - [value] + [v0, v1, v2, v3] ); const curvePath = useMemo( @@ -199,21 +205,33 @@ function CubicBezierEditorComponent({ - {/* Bezier curve */} + {/* Bezier curve — dimmed when easing overlay is active */} + {/* Actual easing function curve overlay */} + {easingCurve && ( + + )} {/* Control point 1 - nodrag nopan touch-none prevents React Flow node dragging */}