Browse Source

Merge pull request #85 from shrimbly/develop

Release: security hardening, output node improvements, schema resolution, and bug fixes
handoff-20260429-1057
Willie 5 months ago
committed by GitHub
parent
commit
7460a90090
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      package-lock.json
  2. 2
      package.json
  3. 177
      src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts
  4. 147
      src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts
  5. 7
      src/app/api/generate/providers/fal.ts
  6. 9
      src/app/api/generate/providers/replicate.ts
  7. 323
      src/app/api/models/[modelId]/__tests__/route.test.ts
  8. 153
      src/app/api/models/[modelId]/route.ts
  9. 48
      src/app/api/workflow-images/__tests__/route.test.ts
  10. 44
      src/app/api/workflow-images/route.ts
  11. 65
      src/app/api/workflow/__tests__/route.test.ts
  12. 29
      src/app/api/workflow/route.ts
  13. 2
      src/components/KeyboardShortcutsDialog.tsx
  14. 3
      src/components/__tests__/OutputNode.test.tsx
  15. 12
      src/components/nodes/ArrayNode.tsx
  16. 28
      src/components/nodes/OutputNode.tsx
  17. 11
      src/hooks/useVideoBlobUrl.ts
  18. 11
      src/lib/quickstart/validation.ts
  19. 9
      src/store/execution/videoProcessingExecutors.ts
  20. 54
      src/store/utils/__tests__/connectedInputs.test.ts
  21. 5
      src/store/utils/connectedInputs.ts
  22. 24
      src/store/workflowStore.ts
  23. 15
      src/utils/__tests__/arrayParser.test.ts
  24. 36
      src/utils/arrayParser.ts
  25. 19
      src/utils/nodeDimensions.ts
  26. 59
      src/utils/pathValidation.ts

2
package-lock.json

@ -14,7 +14,6 @@
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/three": "^0.182.0",
"@xyflow/react": "^12.9.3",
"ai": "^6.0.49",
"autoprefixer": "^10.4.22",
@ -39,6 +38,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.4.0",

2
package.json

@ -40,9 +40,9 @@
"@testing-library/react": "^16.3.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/three": "^0.182.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.4.0",

177
src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts

@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { generateWithFalQueue, clearFalInputMappingCache } from "../fal";
import type { GenerationInput } from "@/lib/providers/types";
/**
* Tests for prompt passthrough when dynamicInputs are present.
*
* Bug: When a generate node has both a prompt AND image input connected,
* the dynamicInputs code path processes images but never reads input.prompt,
* causing "prompt is required" errors from the fal.ai API.
*/
// Captured request bodies from fetch calls
let capturedQueueBody: Record<string, unknown> | null = null;
function makeInput(overrides: Partial<GenerationInput> = {}): GenerationInput {
return {
model: {
id: "fal-ai/test-model",
name: "Test Model",
description: null,
provider: "fal",
capabilities: ["text-to-image"],
},
prompt: "a photo of a cat",
images: [],
parameters: {},
...overrides,
};
}
/**
* Create a mock fetch that intercepts fal.ai API calls.
* - Schema request: returns OpenAPI spec with prompt + image_url properties
* - Queue submission: captures body, returns request_id
* - Status poll: returns COMPLETED
* - Result fetch: returns images array
* - Media fetch: returns fake image
*/
function createMockFetch() {
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
// Schema request (fal.ai model search API with OpenAPI expansion)
if (urlStr.includes("api.fal.ai/v1/models")) {
return new Response(
JSON.stringify({
models: [
{
endpoint_id: "fal-ai/test-model",
openapi: {
paths: {
"/": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
properties: {
prompt: { type: "string", description: "Text prompt" },
image_url: { type: "string", description: "Input image URL" },
},
},
},
},
},
},
},
},
},
},
],
}),
{ status: 200 }
);
}
// Queue submission
if (urlStr.includes("queue.fal.run/fal-ai/test-model") && init?.method === "POST") {
capturedQueueBody = JSON.parse(init.body as string);
return new Response(
JSON.stringify({
request_id: "test-123",
status_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123/status",
response_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123",
}),
{ status: 200 }
);
}
// Status poll
if (urlStr.includes("/requests/test-123/status")) {
return new Response(
JSON.stringify({ status: "COMPLETED" }),
{ status: 200 }
);
}
// Result fetch
if (urlStr.includes("/requests/test-123") && !urlStr.includes("/status")) {
return new Response(
JSON.stringify({
images: [{ url: "https://cdn.fal.ai/test/image.png" }],
}),
{ status: 200 }
);
}
// Media fetch (output image)
if (urlStr.includes("cdn.fal.ai")) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
});
}
return new Response("Not Found", { status: 404 });
});
}
describe("fal.ai prompt passthrough with dynamicInputs", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
capturedQueueBody = null;
clearFalInputMappingCache();
mockFetch = createMockFetch();
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("includes prompt when dynamicInputs has image_url but no prompt", async () => {
const input = makeInput({
prompt: "a photo of a cat",
dynamicInputs: {
image_url: "https://cdn.example.com/img.png",
},
});
await generateWithFalQueue("test-req", "test-api-key", input);
expect(capturedQueueBody).not.toBeNull();
expect(capturedQueueBody!.prompt).toBe("a photo of a cat");
expect(capturedQueueBody!.image_url).toBe("https://cdn.example.com/img.png");
});
it("does not duplicate prompt when dynamicInputs already contains prompt", async () => {
const input = makeInput({
prompt: "a cat",
dynamicInputs: {
prompt: "a dog",
image_url: "https://cdn.example.com/img.png",
},
});
await generateWithFalQueue("test-req", "test-api-key", input);
expect(capturedQueueBody).not.toBeNull();
// dynamicInputs value wins - not overwritten by input.prompt
expect(capturedQueueBody!.prompt).toBe("a dog");
});
it("works without dynamicInputs (existing behavior)", async () => {
const input = makeInput({
prompt: "a cat",
dynamicInputs: undefined,
});
await generateWithFalQueue("test-req", "test-api-key", input);
expect(capturedQueueBody).not.toBeNull();
expect(capturedQueueBody!.prompt).toBe("a cat");
});
});

147
src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts

@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { generateWithReplicate } from "../replicate";
import type { GenerationInput } from "@/lib/providers/types";
/**
* Tests for prompt passthrough when dynamicInputs are present.
*
* Bug: When a generate node has both a prompt AND image input connected,
* the dynamicInputs code path processes images but never reads input.prompt,
* causing "prompt is required" errors from the Replicate API.
*/
// Captured request bodies from fetch calls
let capturedPredictionBody: Record<string, unknown> | null = null;
function makeInput(overrides: Partial<GenerationInput> = {}): GenerationInput {
return {
model: {
id: "owner/test-model",
name: "Test Model",
description: null,
provider: "replicate",
capabilities: ["text-to-image"],
},
prompt: "a photo of a cat",
images: [],
parameters: {},
...overrides,
};
}
/**
* Create a mock fetch that intercepts Replicate API calls.
* - Model info: returns schema with prompt + image_input properties
* - Prediction creation: captures body, returns succeeded
* - Media fetch: returns fake image
*/
function createMockFetch() {
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
// Model info request
if (urlStr.includes("/models/owner/test-model") && !urlStr.includes("/predictions")) {
return new Response(
JSON.stringify({
latest_version: {
id: "abc123",
openapi_schema: {
components: {
schemas: {
Input: {
properties: {
prompt: { type: "string", description: "Text prompt" },
image_input: { type: "string", description: "Input image URL" },
},
},
},
},
},
},
}),
{ status: 200 }
);
}
// Prediction creation
if (urlStr.includes("/predictions") && init?.method === "POST") {
const body = JSON.parse(init.body as string);
capturedPredictionBody = body.input;
return new Response(
JSON.stringify({
id: "pred-123",
status: "succeeded",
output: ["https://replicate.delivery/test/image.png"],
}),
{ status: 201 }
);
}
// Media fetch (output image)
if (urlStr.includes("replicate.delivery")) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
});
}
return new Response("Not Found", { status: 404 });
});
}
describe("Replicate prompt passthrough with dynamicInputs", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
capturedPredictionBody = null;
mockFetch = createMockFetch();
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("includes prompt when dynamicInputs has image_input but no prompt", async () => {
const input = makeInput({
prompt: "a photo of a cat",
dynamicInputs: {
image_input: "https://cdn.example.com/img.png",
},
});
await generateWithReplicate("test-req", "test-api-key", input);
expect(capturedPredictionBody).not.toBeNull();
expect(capturedPredictionBody!.prompt).toBe("a photo of a cat");
expect(capturedPredictionBody!.image_input).toBe("https://cdn.example.com/img.png");
});
it("does not duplicate prompt when dynamicInputs already contains prompt", async () => {
const input = makeInput({
prompt: "a cat",
dynamicInputs: {
prompt: "a dog",
image_input: "https://cdn.example.com/img.png",
},
});
await generateWithReplicate("test-req", "test-api-key", input);
expect(capturedPredictionBody).not.toBeNull();
// dynamicInputs value wins - not overwritten by input.prompt
expect(capturedPredictionBody!.prompt).toBe("a dog");
});
it("works without dynamicInputs (existing behavior)", async () => {
const input = makeInput({
prompt: "a cat",
dynamicInputs: undefined,
});
await generateWithReplicate("test-req", "test-api-key", input);
expect(capturedPredictionBody).not.toBeNull();
expect(capturedPredictionBody!.prompt).toBe("a cat");
});
});

7
src/app/api/generate/providers/fal.ts

@ -288,6 +288,13 @@ export async function generateWithFalQueue(
}
}
Object.assign(requestBody, filteredInputs);
// Ensure prompt is included even when dynamicInputs are present
// (executor sends prompt as top-level field, not in dynamicInputs)
const promptParam = paramMap.prompt || "prompt";
if (input.prompt && !requestBody[promptParam]) {
requestBody[promptParam] = input.prompt;
}
} else {
// Fallback: use schema to map generic input names to model-specific parameter names
if (input.prompt) {

9
src/app/api/generate/providers/replicate.ts

@ -76,7 +76,7 @@ export async function generateWithReplicate(
if (hasDynamicInputs) {
// Apply coerced parameters first, then dynamic inputs override
Object.assign(predictionInput, coerceParameterTypes(input.parameters, parameterTypes));
const { schemaArrayParams } = getInputMappingFromSchema(schema);
const { paramMap, schemaArrayParams } = getInputMappingFromSchema(schema);
// Apply array wrapping based on schema type
for (const [key, value] of Object.entries(input.dynamicInputs!)) {
@ -90,6 +90,13 @@ export async function generateWithReplicate(
}
}
}
// Ensure prompt is included even when dynamicInputs are present
// (executor sends prompt as top-level field, not in dynamicInputs)
const promptParam = paramMap.prompt || "prompt";
if (input.prompt && !predictionInput[promptParam]) {
predictionInput[promptParam] = input.prompt;
}
} else {
// Fallback: use schema to map generic input names to model-specific parameter names
const { paramMap, arrayParams } = getInputMappingFromSchema(schema);

323
src/app/api/models/[modelId]/__tests__/route.test.ts

@ -451,6 +451,329 @@ describe("/api/models/[modelId] schema endpoint", () => {
});
});
describe("real Fal Kling v2.6 pro image-to-video schema", () => {
it("should detect both start_image_url and end_image_url as image inputs", async () => {
// Exact schema structure from Fal API for fal-ai/kling-video/v2.6/pro/image-to-video
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
models: [{
openapi: {
openapi: "3.0.4",
components: {
schemas: {
KlingVideoV26ProImageToVideoInput: {
title: "ImageToVideoV26ProRequest",
type: "object",
properties: {
prompt: {
title: "Prompt",
type: "string",
maxLength: 2500,
},
duration: {
enum: ["5", "10"],
title: "Duration",
type: "string",
description: "The duration of the generated video in seconds",
default: "5",
},
generate_audio: {
title: "Generate Audio",
type: "boolean",
description: "Whether to generate native audio for the video.",
default: true,
},
start_image_url: {
description: "URL of the image to be used for the video",
type: "string",
title: "Start Image Url",
},
end_image_url: {
title: "End Image Url",
type: "string",
description: "URL of the image to be used for the end of the video",
},
negative_prompt: {
title: "Negative Prompt",
type: "string",
maxLength: 2500,
default: "blur, distort, and low quality",
},
},
required: ["prompt", "start_image_url"],
},
},
},
paths: {
"/fal-ai/kling-video/v2.6/pro/image-to-video": {
post: {
requestBody: {
required: true,
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/KlingVideoV26ProImageToVideoInput",
},
},
},
},
},
},
},
},
}],
}),
});
const modelId = `fal-ai/kling-video-real-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
const imageInputNames = data.inputs
.filter((i: { type: string }) => i.type === "image")
.map((i: { name: string }) => i.name);
const textInputNames = data.inputs
.filter((i: { type: string }) => i.type === "text")
.map((i: { name: string }) => i.name);
const paramNames = data.parameters.map((p: { name: string }) => p.name);
// Both image URL fields should be detected as image inputs
expect(imageInputNames).toContain("start_image_url");
expect(imageInputNames).toContain("end_image_url");
// Text inputs
expect(textInputNames).toContain("prompt");
expect(textInputNames).toContain("negative_prompt");
// Parameters (not image or text inputs)
expect(paramNames).toContain("duration");
expect(paramNames).toContain("generate_audio");
// Image inputs should NOT appear as parameters
expect(paramNames).not.toContain("start_image_url");
expect(paramNames).not.toContain("end_image_url");
});
});
describe("anyOf/oneOf nullable schema patterns", () => {
// Helper to create fal.ai response with components.schemas for $ref resolution
function createFalModelResponseWithComponents(
inputProperties: Record<string, unknown>,
required: string[] = [],
components?: Record<string, unknown>
) {
return {
ok: true,
json: () => Promise.resolve({
models: [{
openapi: {
paths: {
"/": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Input",
},
},
},
},
},
},
},
components: {
schemas: {
Input: {
type: "object",
properties: inputProperties,
required,
},
...components,
},
},
},
}],
}),
};
}
it("should detect anyOf nullable string as image input (Kling pattern)", async () => {
// Exact pattern from Kling v2.6 image-to-video on Fal
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
prompt: {
type: "string",
description: "Text prompt",
},
image_url: {
type: "string",
description: "The URL of the image",
},
end_image_url: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "The URL of the end image",
},
}, ["prompt", "image_url"])
);
const modelId = `fal-ai/kling-anyof-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const imageInputNames = data.inputs
.filter((i: { type: string }) => i.type === "image")
.map((i: { name: string }) => i.name);
// Both image_url and end_image_url should be image inputs
expect(imageInputNames).toContain("image_url");
expect(imageInputNames).toContain("end_image_url");
});
it("should detect anyOf with format: uri as image input", async () => {
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
reference_image: {
anyOf: [
{ type: "string", format: "uri" },
{ type: "null" },
],
description: "Reference image URL",
},
})
);
const modelId = `fal-ai/anyof-uri-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const imageInputNames = data.inputs
.filter((i: { type: string }) => i.type === "image")
.map((i: { name: string }) => i.name);
expect(imageInputNames).toContain("reference_image");
});
it("should NOT detect anyOf with non-string types as image input", async () => {
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
image_guidance_scale: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Image guidance scale",
},
image_count: {
anyOf: [{ type: "integer" }, { type: "null" }],
description: "Number of images",
},
})
);
const modelId = `fal-ai/anyof-nonstring-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const paramNames = data.parameters.map((p: { name: string }) => p.name);
const inputNames = data.inputs.map((i: { name: string }) => i.name);
expect(paramNames).toContain("image_guidance_scale");
expect(paramNames).toContain("image_count");
expect(inputNames).not.toContain("image_guidance_scale");
expect(inputNames).not.toContain("image_count");
});
it("should handle oneOf pattern same as anyOf", async () => {
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
start_image: {
oneOf: [{ type: "string" }, { type: "null" }],
description: "The start image URL",
},
})
);
const modelId = `fal-ai/oneof-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const imageInputNames = data.inputs
.filter((i: { type: string }) => i.type === "image")
.map((i: { name: string }) => i.name);
expect(imageInputNames).toContain("start_image");
});
it("should resolve anyOf parameter types correctly (not default to string)", async () => {
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
seed: {
anyOf: [{ type: "integer" }, { type: "null" }],
description: "Random seed",
},
guidance_scale: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Guidance scale",
default: 7.5,
},
})
);
const modelId = `fal-ai/anyof-types-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const seedParam = data.parameters.find((p: { name: string }) => p.name === "seed");
const guidanceParam = data.parameters.find((p: { name: string }) => p.name === "guidance_scale");
expect(seedParam?.type).toBe("integer");
expect(guidanceParam?.type).toBe("number");
});
it("should NOT classify anyOf boolean with image in name as image input", async () => {
mockFetch.mockResolvedValueOnce(
createFalModelResponseWithComponents({
enable_image_enhancement: {
anyOf: [{ type: "boolean" }, { type: "null" }],
description: "Enable image enhancement",
default: false,
},
})
);
const modelId = `fal-ai/anyof-bool-${testCounter}`;
const request = createMockSchemaRequest(modelId, "fal");
const response = await GET(request, { params: Promise.resolve({ modelId }) });
const data = await response.json();
expect(response.status).toBe(200);
const paramNames = data.parameters.map((p: { name: string }) => p.name);
const inputNames = data.inputs.map((i: { name: string }) => i.name);
expect(paramNames).toContain("enable_image_enhancement");
expect(inputNames).not.toContain("enable_image_enhancement");
});
});
describe("error handling", () => {
it("should return 400 for invalid provider", async () => {
const request = createMockSchemaRequest("test/model", "invalid");

153
src/app/api/models/[modelId]/route.ts

@ -123,10 +123,11 @@ function toLabel(name: string): string {
* Image inputs must be strings (URLs or base64) or arrays of strings.
* Integers, booleans, numbers with "image" in the name are NOT image inputs.
*/
function isImageInput(name: string, prop: Record<string, unknown>): boolean {
function isImageInput(name: string, prop: Record<string, unknown>, schemaComponents?: Record<string, unknown>): boolean {
// First check: must be a string type (images are URLs or base64 strings)
// Integers, booleans, numbers are NEVER image inputs regardless of name
const propType = prop.type as string | undefined;
const resolved = resolvePropertyType(prop, schemaComponents);
const propType = resolved.type;
if (propType !== "string" && propType !== "array") {
return false;
}
@ -146,8 +147,8 @@ function isImageInput(name: string, prop: Record<string, unknown>): boolean {
return false;
}
// Check format hints (OpenAPI format field) - strong signal for image URLs
const format = prop.format as string | undefined;
// Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs
const format = (prop.format ?? resolved.format) as string | undefined;
if (format === "uri" || format === "data-uri" || format === "binary") {
// Only treat as image if name also suggests it's an image
if (IMAGE_INPUT_PATTERNS.includes(name) ||
@ -215,6 +216,68 @@ function resolveRef(
return resolved || null;
}
/**
* Resolve the effective type and format from an OpenAPI property.
*
* Handles wrapper patterns used by code generators (e.g. Pydantic OpenAPI):
* - anyOf / oneOf: picks the first non-null type (nullable pattern)
* - allOf: merges referenced schemas
* - $ref: resolves from schemaComponents
* - Direct type: returns immediately (fast path no behavior change)
*/
function resolvePropertyType(
prop: Record<string, unknown>,
schemaComponents?: Record<string, unknown>
): { type?: string; format?: string } {
// Fast path: direct type is defined — existing behaviour, no change
if (prop.type !== undefined) {
return { type: prop.type as string, format: prop.format as string | undefined };
}
// anyOf / oneOf — pick the first non-null variant
const variants = (prop.anyOf ?? prop.oneOf) as Array<Record<string, unknown>> | undefined;
if (variants && Array.isArray(variants)) {
for (const variant of variants) {
// Resolve $ref inside variant
if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) {
const resolved = resolveRef(variant.$ref as string, schemaComponents);
if (resolved && resolved.type && resolved.type !== "null") {
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
}
}
if (variant.type && variant.type !== "null") {
return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined };
}
}
}
// allOf — merge referenced schemas
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
if (allOf && Array.isArray(allOf) && schemaComponents) {
for (const item of allOf) {
if (item.$ref && typeof item.$ref === "string") {
const resolved = resolveRef(item.$ref as string, schemaComponents);
if (resolved && resolved.type) {
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
}
}
if (item.type) {
return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined };
}
}
}
// $ref at top level
if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) {
const resolved = resolveRef(prop.$ref as string, schemaComponents);
if (resolved && resolved.type) {
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
}
}
return {};
}
/**
* Convert OpenAPI schema property to ModelParameter
*/
@ -229,55 +292,80 @@ function convertSchemaProperty(
return null;
}
// Determine type and extract enum from allOf/$ref if present
// Determine type and extract enum from allOf/$ref/anyOf/oneOf if present
let type: ModelParameter["type"] = "string";
let enumValues: unknown[] | undefined;
let resolvedDefault: unknown;
let resolvedDescription: string | undefined;
const schemaType = prop.type as string | undefined;
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
// Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns
const resolved = resolvePropertyType(prop, schemaComponents);
const effectiveType = resolved.type;
if (schemaType === "integer") {
if (effectiveType === "integer") {
type = "integer";
} else if (schemaType === "number") {
} else if (effectiveType === "number") {
type = "number";
} else if (schemaType === "boolean") {
} else if (effectiveType === "boolean") {
type = "boolean";
} else if (schemaType === "array") {
} else if (effectiveType === "array") {
type = "array";
} else if (allOf && allOf.length > 0 && schemaComponents) {
// Handle allOf with $ref - resolve references and extract enum/type
}
// Extract enum/default/description from allOf with $ref
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
if (allOf && allOf.length > 0 && schemaComponents) {
for (const item of allOf) {
const itemRef = item.$ref as string | undefined;
if (itemRef) {
const resolved = resolveRef(itemRef, schemaComponents);
if (resolved) {
// Extract type from resolved schema
if (resolved.type === "integer") type = "integer";
else if (resolved.type === "number") type = "number";
else if (resolved.type === "boolean") type = "boolean";
// Extract enum from resolved schema
if (Array.isArray(resolved.enum)) {
enumValues = resolved.enum;
const refResolved = resolveRef(itemRef, schemaComponents);
if (refResolved) {
if (Array.isArray(refResolved.enum)) {
enumValues = refResolved.enum;
}
// Extract default from resolved schema
if (resolved.default !== undefined && resolvedDefault === undefined) {
resolvedDefault = resolved.default;
if (refResolved.default !== undefined && resolvedDefault === undefined) {
resolvedDefault = refResolved.default;
}
// Extract description from resolved schema
if (resolved.description && !resolvedDescription) {
resolvedDescription = resolved.description as string;
if (refResolved.description && !resolvedDescription) {
resolvedDescription = refResolved.description as string;
}
}
} else if (Array.isArray(item.enum)) {
// Direct enum in allOf item
enumValues = item.enum;
}
}
}
// Extract enum/default/description from anyOf/oneOf variants
const variants = (prop.anyOf ?? prop.oneOf) as Array<Record<string, unknown>> | undefined;
if (variants && Array.isArray(variants)) {
for (const variant of variants) {
if (variant.type === "null") continue;
// Resolve $ref inside variant
if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) {
const refResolved = resolveRef(variant.$ref as string, schemaComponents);
if (refResolved) {
if (Array.isArray(refResolved.enum) && !enumValues) {
enumValues = refResolved.enum;
}
if (refResolved.default !== undefined && resolvedDefault === undefined) {
resolvedDefault = refResolved.default;
}
if (refResolved.description && !resolvedDescription) {
resolvedDescription = refResolved.description as string;
}
}
} else {
if (Array.isArray(variant.enum) && !enumValues) {
enumValues = variant.enum;
}
if (variant.default !== undefined && resolvedDefault === undefined) {
resolvedDefault = variant.default;
}
}
}
}
const parameter: ModelParameter = {
name,
type,
@ -439,14 +527,15 @@ function extractParametersFromSchema(
for (const [name, prop] of Object.entries(properties)) {
// Check if this is a connectable input (image or text)
// Pass both name AND prop to check schema type, not just name
if (isImageInput(name, prop)) {
if (isImageInput(name, prop, schemaComponents)) {
const resolvedType = resolvePropertyType(prop, schemaComponents).type;
inputs.push({
name,
type: "image",
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
isArray: prop.type === "array",
isArray: resolvedType === "array",
});
continue;
}

48
src/app/api/workflow-images/__tests__/route.test.ts

@ -80,5 +80,53 @@ describe("/api/workflow-images route", () => {
expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow", { recursive: true });
expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow/inputs", { recursive: true });
});
it("should reject path traversal attempts", async () => {
const request = createMockPostRequest({
workflowPath: "/test/../etc/passwd",
imageId: "img_123",
folder: "inputs",
imageData: "data:image/png;base64,aGVsbG8=",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path contains traversal sequences");
});
it("should reject non-absolute paths", async () => {
const request = createMockPostRequest({
workflowPath: "relative/path",
imageId: "img_123",
folder: "inputs",
imageData: "data:image/png;base64,aGVsbG8=",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path must be absolute");
});
it("should reject dangerous system paths", async () => {
const request = createMockPostRequest({
workflowPath: "/etc/workflows",
imageId: "img_123",
folder: "inputs",
imageData: "data:image/png;base64,aGVsbG8=",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Access to /etc is not allowed");
});
});
});

44
src/app/api/workflow-images/route.ts

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import * as fs from "fs/promises";
import * as path from "path";
import { logger } from "@/utils/logger";
import { validateWorkflowPath } from "@/utils/pathValidation";
export const maxDuration = 300; // 5 minute timeout for large image operations
@ -62,6 +63,19 @@ export async function POST(request: NextRequest) {
);
}
// Validate path to prevent traversal attacks
const pathValidation = validateWorkflowPath(workflowPath);
if (!pathValidation.valid) {
logger.warn('file.error', 'Workflow image save failed: invalid path', {
workflowPath,
error: pathValidation.error,
});
return NextResponse.json(
{ success: false, error: pathValidation.error },
{ status: 400 }
);
}
// Validate workflow directory exists, or create it if missing
try {
const stats = await fs.stat(workflowPath);
@ -75,6 +89,23 @@ export async function POST(request: NextRequest) {
);
}
} catch (dirError) {
const err = dirError as NodeJS.ErrnoException;
const isNotFound =
err?.code === "ENOENT" ||
(typeof err?.message === "string" &&
(err.message.includes("ENOENT") || err.message.includes("no such file or directory")));
if (!isNotFound) {
logger.warn('file.error', 'Workflow image save failed: directory validation error', {
workflowPath,
error: dirError instanceof Error ? dirError.message : 'Unknown error',
});
return NextResponse.json(
{ success: false, error: "Directory validation failed" },
{ status: 400 }
);
}
try {
await fs.mkdir(workflowPath, { recursive: true });
logger.info('file.save', 'Created workflow directory for image save', {
@ -176,6 +207,19 @@ export async function GET(request: NextRequest) {
}
try {
// Validate path to prevent traversal attacks
const pathValidation = validateWorkflowPath(workflowPath);
if (!pathValidation.valid) {
logger.warn('file.error', 'Workflow image load failed: invalid path', {
workflowPath,
error: pathValidation.error,
});
return NextResponse.json(
{ success: false, error: pathValidation.error },
{ status: 400 }
);
}
// Sanitize imageId to prevent path traversal
const safeImageId = path.basename(imageId);
if (safeImageId !== imageId || imageId.includes('..')) {

65
src/app/api/workflow/__tests__/route.test.ts

@ -249,6 +249,51 @@ describe("/api/workflow route", () => {
expect(data.success).toBe(false);
expect(data.error).toBe("Disk full");
});
it("should reject path traversal attempts", async () => {
const request = createMockPostRequest({
directoryPath: "/test/../etc/passwd",
filename: "workflow",
workflow: { nodes: [], edges: [] },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path contains traversal sequences");
});
it("should reject non-absolute paths", async () => {
const request = createMockPostRequest({
directoryPath: "relative/path",
filename: "workflow",
workflow: { nodes: [], edges: [] },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path must be absolute");
});
it("should reject dangerous system paths", async () => {
const request = createMockPostRequest({
directoryPath: "/etc/workflows",
filename: "workflow",
workflow: { nodes: [], edges: [] },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Access to /etc is not allowed");
});
});
describe("GET - Validate directory", () => {
@ -301,5 +346,25 @@ describe("/api/workflow route", () => {
expect(data.success).toBe(false);
expect(data.error).toBe("Path parameter required");
});
it("should reject path traversal attempts in GET", async () => {
const request = createMockGetRequest({ path: "/test/../etc" });
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path contains traversal sequences");
});
it("should reject non-absolute paths in GET", async () => {
const request = createMockGetRequest({ path: "relative/path" });
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe("Path must be absolute");
});
});
});

29
src/app/api/workflow/route.ts

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import * as fs from "fs/promises";
import * as path from "path";
import { logger } from "@/utils/logger";
import { validateWorkflowPath } from "@/utils/pathValidation";
export const maxDuration = 300; // 5 minute timeout for large workflow files
@ -35,6 +36,19 @@ export async function POST(request: NextRequest) {
);
}
// Validate path to prevent traversal attacks
const pathValidation = validateWorkflowPath(directoryPath);
if (!pathValidation.valid) {
logger.warn('file.error', 'Workflow save failed: invalid path', {
directoryPath,
error: pathValidation.error,
});
return NextResponse.json(
{ success: false, error: pathValidation.error },
{ status: 400 }
);
}
// Ensure project directory exists (supports saving into new subfolders)
try {
const stats = await fs.stat(directoryPath);
@ -74,7 +88,7 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json(
{ success: false, error: "Could not create directory" },
{ status: 400 }
{ status: 500 }
);
}
}
@ -143,6 +157,19 @@ export async function GET(request: NextRequest) {
);
}
// Validate path to prevent traversal attacks
const pathValidation = validateWorkflowPath(directoryPath);
if (!pathValidation.valid) {
logger.warn('file.error', 'Directory validation failed: invalid path', {
directoryPath,
error: pathValidation.error,
});
return NextResponse.json(
{ success: false, error: pathValidation.error },
{ status: 400 }
);
}
try {
const stats = await fs.stat(directoryPath);
const isDirectory = stats.isDirectory();

2
src/components/KeyboardShortcutsDialog.tsx

@ -34,6 +34,8 @@ const shortcutGroups: ShortcutGroup[] = [
{ keys: ["Shift", "V"], description: "Add Generate Video node" },
{ keys: ["Shift", "L"], description: "Add LLM Text node" },
{ keys: ["Shift", "A"], description: "Add Annotation node" },
{ keys: ["Shift", "T"], description: "Add Audio node" },
{ keys: ["Shift", "R"], description: "Add Array node" },
],
},
{

3
src/components/__tests__/OutputNode.test.tsx

@ -39,6 +39,9 @@ describe("OutputNode", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: vi.fn(),
edges: [],
isRunning: false,
currentNodeIds: [],
groups: {},
nodes: [],

12
src/components/nodes/ArrayNode.tsx

@ -150,8 +150,8 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
// Reset selection if it no longer points to a valid parsed item.
useEffect(() => {
const selected = nodeData.selectedOutputIndex;
if (selected !== null && (selected < 0 || selected >= previewItems.length)) {
const currentSelection = nodeData.selectedOutputIndex;
if (currentSelection !== null && (currentSelection < 0 || currentSelection >= previewItems.length)) {
updateNodeData(id, { selectedOutputIndex: null });
}
}, [id, nodeData.selectedOutputIndex, previewItems.length, updateNodeData]);
@ -163,9 +163,11 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
const newHeight = Math.max(baseHeight, 270 + previewItems.length * perItemHeight);
setNodes((nodes) =>
nodes.map((node) =>
node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node
)
nodes.map((node) => {
if (node.id !== id) return node;
if ((node.style?.height as number) === newHeight) return node;
return { ...node, style: { ...node.style, height: newHeight } };
})
);
}, [id, previewItems.length, setNodes]);

28
src/components/nodes/OutputNode.tsx

@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, useMemo } from "react";
import { useCallback, useState, useMemo, useEffect, useRef } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
@ -14,7 +14,13 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const connectedEdgeCount = useWorkflowStore(
(state) => state.edges.filter((edge) => edge.target === id).length
);
const isRunning = useWorkflowStore((state) => state.isRunning);
const [showLightbox, setShowLightbox] = useState(false);
const previousEdgeCountRef = useRef<number | null>(null);
// Determine if content is audio
const isAudio = useMemo(() => {
@ -43,6 +49,24 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null);
// Auto-trigger execution when a new connection is made
useEffect(() => {
if (previousEdgeCountRef.current === null) {
// First run — just record the baseline, don't trigger
previousEdgeCountRef.current = connectedEdgeCount;
return;
}
if (connectedEdgeCount > previousEdgeCountRef.current) {
regenerateNode(id);
}
previousEdgeCountRef.current = connectedEdgeCount;
}, [connectedEdgeCount, id, regenerateNode]);
// Handle Run button click
const handleRun = useCallback(() => {
regenerateNode(id);
}, [id, regenerateNode]);
const handleDownload = useCallback(async () => {
if (!contentSrc) return;
@ -91,6 +115,8 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
onRun={handleRun}
isExecuting={isRunning}
selected={selected}
className="min-w-[200px]"
commentNavigation={commentNavigation ?? undefined}

11
src/hooks/useVideoBlobUrl.ts

@ -17,13 +17,8 @@ import { useEffect, useRef, useState } from "react";
export function useVideoBlobUrl(videoUrl: string | null): string | null {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const prevBlobUrlRef = useRef<string | null>(null);
const prevInputRef = useRef<string | null>(null);
useEffect(() => {
// Input unchanged — skip
if (videoUrl === prevInputRef.current) return;
prevInputRef.current = videoUrl;
// Revoke previous blob URL
if (prevBlobUrlRef.current) {
URL.revokeObjectURL(prevBlobUrlRef.current);
@ -47,11 +42,13 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null {
setBlobUrl(videoUrl);
let cancelled = false;
let createdUrl: string | null = null;
fetch(videoUrl)
.then((r) => r.blob())
.then((blob) => {
if (cancelled) return;
const url = URL.createObjectURL(blob);
createdUrl = url;
prevBlobUrlRef.current = url;
setBlobUrl(url);
})
@ -61,6 +58,10 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null {
return () => {
cancelled = true;
// If a blob URL was created after we decided to cancel, revoke it
if (createdUrl && createdUrl !== prevBlobUrlRef.current) {
URL.revokeObjectURL(createdUrl);
}
};
}

11
src/lib/quickstart/validation.ts

@ -13,9 +13,11 @@ interface ValidationResult {
const VALID_NODE_TYPES: NodeType[] = [
"imageInput",
"audioInput",
"annotation",
"prompt",
"array",
"promptConstructor",
"nanoBanana",
"generateVideo",
"generate3d",
@ -23,9 +25,16 @@ const VALID_NODE_TYPES: NodeType[] = [
"llmGenerate",
"splitGrid",
"output",
"outputGallery",
"imageCompare",
"videoStitch",
"easeCurve",
"videoTrim",
"videoFrameGrab",
"glbViewer",
];
const VALID_HANDLE_TYPES = ["image", "text", "audio", "reference"];
const VALID_HANDLE_TYPES = ["image", "text", "audio", "video", "easeCurve", "3d", "reference"];
// Default node dimensions
const DEFAULT_DIMENSIONS: Record<NodeType, { width: number; height: number }> = {

9
src/store/execution/videoProcessingExecutors.ts

@ -55,7 +55,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise<voi
// Prepare audio if connected
let audioData = null;
if (inputs.audio.length > 0 && inputs.audio[0]) {
console.log('[VideoStitch] Audio input detected, preparing audio...');
const { prepareAudioAsync } = await import("@/hooks/useAudioMixing");
const audioUrl = inputs.audio[0];
const audioResponse = await fetch(audioUrl);
@ -69,14 +68,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise<voi
? rawBlob
: new Blob([rawBlob], { type: audioMime });
audioData = await prepareAudioAsync(audioBlob, 0);
if (!audioData) {
console.warn('[VideoStitch] prepareAudioAsync returned null — audio will be skipped');
} else {
console.log('[VideoStitch] Audio prepared: %ds, %d channels, %dHz',
audioData.duration.toFixed(1), audioData.buffer.numberOfChannels, audioData.buffer.sampleRate);
}
} else {
console.log('[VideoStitch] No audio connected (inputs.audio.length=%d)', inputs.audio.length);
}
const { stitchVideosAsync } = await import("@/hooks/useStitchVideos");

54
src/store/utils/__tests__/connectedInputs.test.ts

@ -140,6 +140,60 @@ describe("getConnectedInputsPure", () => {
expect(result.text).toBe("three");
});
it("should wrap out-of-bounds arrayItemIndex via modulo", () => {
const nodes = [
makeNode("arr", "array", { outputText: "[\"one\",\"two\"]", outputItems: ["one", "two"] }),
makeNode("gen", "nanoBanana"),
];
const edges = [{
id: "arr-gen-stale",
source: "arr",
target: "gen",
sourceHandle: "text",
targetHandle: "text",
data: { arrayItemIndex: 3 }, // index 3 on 2-item array wraps to index 1
}] as WorkflowEdge[];
const result = getConnectedInputsPure("gen", nodes, edges);
expect(result.text).toBe("two");
});
it("should return null for out-of-bounds arrayItemIndex on empty array", () => {
const nodes = [
makeNode("arr", "array", { outputText: "[]", outputItems: [] }),
makeNode("gen", "nanoBanana"),
];
const edges = [{
id: "arr-gen-empty",
source: "arr",
target: "gen",
sourceHandle: "text",
targetHandle: "text",
data: { arrayItemIndex: 0 },
}] as WorkflowEdge[];
const result = getConnectedInputsPure("gen", nodes, edges);
expect(result.text).toBeNull();
});
it("should wrap large arrayItemIndex correctly", () => {
const nodes = [
makeNode("arr", "array", { outputText: "[\"a\",\"b\",\"c\"]", outputItems: ["a", "b", "c"] }),
makeNode("gen", "nanoBanana"),
];
const edges = [{
id: "arr-gen-large",
source: "arr",
target: "gen",
sourceHandle: "text",
targetHandle: "text",
data: { arrayItemIndex: 7 }, // 7 % 3 = 1
}] as WorkflowEdge[];
const result = getConnectedInputsPure("gen", nodes, edges);
expect(result.text).toBe("b");
});
it("should extract text from promptConstructor outputText", () => {
const nodes = [
makeNode("pc", "promptConstructor", { outputText: "constructed", template: "tmpl" }),

5
src/store/utils/connectedInputs.ts

@ -91,7 +91,10 @@ function getSourceOutput(
const arrayData = sourceNode.data as ArrayNodeData;
const dataIndex = edgeData?.arrayItemIndex;
if (typeof dataIndex === "number" && Number.isInteger(dataIndex) && dataIndex >= 0) {
return { type: "text", value: arrayData.outputItems[dataIndex] ?? null };
const items = arrayData.outputItems;
if (items.length === 0) return { type: "text", value: null };
const clampedIndex = dataIndex % items.length;
return { type: "text", value: items[clampedIndex] ?? null };
}
if (sourceHandle?.startsWith("text-")) {
const index = Number(sourceHandle.replace("text-", ""));

24
src/store/workflowStore.ts

@ -1,4 +1,4 @@
import { create } from "zustand";
import { create, StateCreator } from "zustand";
import { useShallow } from "zustand/shallow";
import {
Connection,
@ -333,6 +333,7 @@ interface WorkflowStore {
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void;
}
let nodeIdCounter = 0;
@ -369,7 +370,7 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void>
export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage";
export { GROUP_COLORS } from "./utils/nodeDefaults";
export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
nodes: [],
edges: [],
edgeStyle: "curved" as EdgeStyle,
@ -1167,6 +1168,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
} else if (node.type === "output") {
await executeOutput(executionCtx);
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
}
// After regeneration, execute directly connected downstream consumer nodes
@ -1535,8 +1541,8 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const configs = loadSaveConfigs();
const savedConfig = workflow.id ? configs[workflow.id] : null;
// Determine the workflow directory path (passed in, from saved config, or embedded in workflow)
const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath;
// Determine the workflow directory path (passed in, from saved config, or embedded in legacy workflow JSON)
const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath || null;
// Hydrate images if we have a directory path and the workflow has image refs
let hydratedWorkflow = workflow;
@ -1731,7 +1737,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
version: 1,
id: workflowId,
name: workflowName,
directoryPath: saveDirectoryPath,
nodes: currentNodes,
edges,
edgeStyle,
@ -1840,7 +1845,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return false;
}
const { saveDirectoryPath, workflowId: prevId, workflowName: prevName } = get();
const { saveDirectoryPath, workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved } = get();
if (!saveDirectoryPath) {
return false;
}
@ -1856,7 +1861,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const success = await get().saveToFile();
if (!success) {
// Rollback to previous identity on failure
set({ workflowId: prevId, workflowName: prevName });
set({ workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved });
}
return success;
},
@ -2111,7 +2116,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ canvasNavigationSettings: settings });
saveCanvasNavigationSettings(settings);
},
}));
});
export const useWorkflowStore = create<WorkflowStore>()(workflowStoreImpl);
/**
* Stable hook for provider API keys.

15
src/utils/__tests__/arrayParser.test.ts

@ -80,6 +80,21 @@ describe("parseTextToArray", () => {
}
});
it("rejects deeply nested quantifier patterns that bypass simple regex checks", () => {
const deeplyNestedPatterns = ["((a+))+", "((a*)+)", "(((x+))+)+"];
for (const pattern of deeplyNestedPatterns) {
const result = parseTextToArray("test", {
splitMode: "regex",
delimiter: "*",
regexPattern: pattern,
trimItems: true,
removeEmpty: true,
});
expect(result.items).toEqual([]);
expect(result.error).toContain("nested quantifiers");
}
});
it("allows safe regex patterns", () => {
const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"];
for (const pattern of safePatterns) {

36
src/utils/arrayParser.ts

@ -18,14 +18,42 @@ const MAX_REGEX_INPUT_LENGTH = 100_000;
/**
* Detect regex patterns prone to catastrophic backtracking (ReDoS).
* Rejects nested quantifiers like (a+)+, (a*)+, (a+)*, etc.
* Rejects nested quantifiers like (a+)+, (a*)+, ((a+))+, etc.
* Uses character-by-character parsing to track nesting depth.
*/
function isUnsafePattern(pattern: string): boolean {
// Strip /pattern/flags wrapper to inspect the body
const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i);
const body = slashFormat ? slashFormat[1] : pattern;
// Nested quantifiers: a group containing a quantifier, followed by another quantifier
return /\([^)]*[+*]\)[+*{]/.test(body);
// Track groups: when we see ')' followed by a quantifier,
// check if anything inside that group also had a quantifier.
let depth = 0;
const quantifierAtDepth: boolean[] = [];
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === '\\') { i++; continue; } // skip escaped chars
if (ch === '(') {
depth++;
quantifierAtDepth[depth] = false;
} else if (ch === ')') {
const hadQuantifier = quantifierAtDepth[depth] || false;
depth = Math.max(0, depth - 1);
// Check if this closing paren is followed by a quantifier
const next = body[i + 1];
if (next === '+' || next === '*' || next === '{') {
if (hadQuantifier) return true; // nested quantifier!
// Mark parent depth as having a quantifier
quantifierAtDepth[depth] = true;
} else if (hadQuantifier) {
// Group contained quantifier but isn't followed by one - propagate to parent
quantifierAtDepth[depth] = true;
}
} else if ((ch === '+' || ch === '*') && depth > 0) {
quantifierAtDepth[depth] = true;
}
}
return false;
}
function parseRegexPattern(pattern: string): RegExp {

19
src/utils/nodeDimensions.ts

@ -16,20 +16,29 @@ export function getImageDimensions(
return;
}
let resolved = false;
const img = new Image();
const cleanup = () => {
img.onload = null;
img.onerror = null;
img.src = "";
};
img.onload = () => {
const dims = { width: img.naturalWidth, height: img.naturalHeight };
const safeResolve = (value: { width: number; height: number } | null) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(dims);
resolve(value);
};
const timeout = setTimeout(() => safeResolve(null), 10_000);
img.onload = () => {
clearTimeout(timeout);
safeResolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
cleanup();
resolve(null);
clearTimeout(timeout);
safeResolve(null);
};
img.src = base64DataUrl;
});

59
src/utils/pathValidation.ts

@ -0,0 +1,59 @@
import * as path from "path";
/**
* Validates a workflow directory path to prevent path traversal attacks.
* Ensures the path is absolute, doesn't contain traversal sequences,
* and doesn't point to dangerous system directories.
*/
export function validateWorkflowPath(inputPath: string): {
valid: boolean;
resolved: string;
error?: string;
} {
// Must be an absolute path
if (!path.isAbsolute(inputPath)) {
return {
valid: false,
resolved: inputPath,
error: "Path must be absolute",
};
}
// Resolve the path and ensure it equals the input (catches .. traversal)
const resolved = path.resolve(inputPath);
if (resolved !== inputPath) {
return {
valid: false,
resolved,
error: "Path contains traversal sequences",
};
}
// Block known dangerous system directories
const dangerousPrefixes = [
"/etc",
"/usr",
"/bin",
"/sbin",
"/sys",
"/proc",
"/var/run",
"/System",
"/Library",
];
for (const prefix of dangerousPrefixes) {
if (resolved.startsWith(prefix + "/") || resolved === prefix) {
return {
valid: false,
resolved,
error: `Access to ${prefix} is not allowed`,
};
}
}
return {
valid: true,
resolved,
};
}
Loading…
Cancel
Save