Skip to content
Merged
19 changes: 17 additions & 2 deletions frontend/app/api/launch_kit/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { fetchUrlContent } from "../../../lib/context/linkFetcher";
import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage";
import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs";
import { ProviderError, providerErrorPayload } from "../../../lib/ai/providerErrors.mjs";
import { readGenerationRequestBody } from "../../../lib/server/generationRequestBody.mjs";

const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]);

Expand All @@ -24,7 +25,19 @@ export async function POST(request) {
const isOwner = accessError === null;

try {
const parsedBody = await request.json();
const parsedRequest = await readGenerationRequestBody(request);
if (!parsedRequest.ok) {
return new Response(JSON.stringify({
ok: false,
code: parsedRequest.code,
error: parsedRequest.error,
limitIssues: parsedRequest.issues,
}), {
status: parsedRequest.status,
headers: { "Content-Type": "application/json" },
});
}
const parsedBody = parsedRequest.body;
const body = parsedBody && typeof parsedBody === "object" && !Array.isArray(parsedBody)
? parsedBody
: {};
Expand Down Expand Up @@ -66,8 +79,10 @@ export async function POST(request) {
if (!validation.valid) {
return new Response(JSON.stringify({
ok: false,
error: "Validation failed",
code: validation.limitIssues.length ? "generation_limit_exceeded" : "validation_failed",
error: validation.limitIssues[0]?.message || "Validation failed",
warnings: validation.errors,
limitIssues: validation.limitIssues,
}), {
status: 400,
headers: { "Content-Type": "application/json" },
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ ${extractedText}`);
return { strategyBlocked: true, data };
}
if (!response.ok || data.ok === false) {
const generationError = new Error(data.providerError?.message || data.error || "SignalFlow could not generate this campaign.");
const generationError = new Error(data.limitIssues?.[0]?.message || data.providerError?.message || data.error || "SignalFlow could not generate this campaign.");
generationError.providerError = data.providerError || null;
throw generationError;
}
Expand Down
25 changes: 25 additions & 0 deletions frontend/lib/package/generationLimits.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const GENERATION_LIMITS = Object.freeze({
requestBytes: 512 * 1024,
projectNameChars: 240,
notesChars: 40_000,
audienceChars: 4_000,
linksChars: 16_000,
linksCount: 8,
documentItems: 12,
documentChars: 120_000,
totalTextContextChars: 160_000,
channels: 12,
outputTypes: 8,
sourceRecordsPerKind: 24,
mediaItems: 24,
});

export function generationLimitIssue({ code, field, message, actual, max }) {
return Object.freeze({
code: String(code),
field: String(field),
message: String(message),
actual: Number(actual),
max: Number(max),
});
}
82 changes: 79 additions & 3 deletions frontend/lib/package/validatePackage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,94 @@ import {
normalizeDocumentText,
normalizeTextInput,
} from "./inputNormalization.mjs";
import {
GENERATION_LIMITS,
generationLimitIssue,
} from "./generationLimits.mjs";

/**
* Validates the generation inputs from the client.
* Returns { valid: boolean, errors: string[] }
*/
export function validateGenerationInputs(body = {}) {
const errors = [];
const limitIssues = [];

const addLimit = (issue) => {
limitIssues.push(issue);
errors.push(issue.message);
};

const projectName = normalizeTextInput(body?.project_name ?? body?.projectName);
const notes = normalizeTextInput(body?.notes);
const audience = normalizeTextInput(body?.audience);
const repo = normalizeTextInput(body?.repo);
const documentText = normalizeDocumentText(body?.document_text).join("\n\n");
const documentItems = normalizeDocumentText(body?.document_text);
const documentText = documentItems.join("\n\n");
const researchUrl = normalizeTextInput(body?.research_url ?? body?.docs_url);
const urls = researchUrl ? researchUrl.split(/\s+/).filter(Boolean) : [];
const channels = Array.isArray(body?.channels) ? body.channels.filter(Boolean) : [];
const outputTypes = Array.isArray(body?.output_types) ? body.output_types.filter(Boolean) : [];
const assets = Array.isArray(body?.assets) ? body.assets : [];
const sourceArtifacts = Array.isArray(body?.source_artifacts ?? body?.sourceArtifacts)
? (body.source_artifacts ?? body.sourceArtifacts)
: [];
const processingRecords = Array.isArray(body?.processing_records ?? body?.processingRecords)
? (body.processing_records ?? body.processingRecords)
: [];
const mediaItems = Array.isArray(body?.media_items) ? body.media_items : [];

const textChecks = [
["generation_limit.project_name_chars", "project_name", projectName.length, GENERATION_LIMITS.projectNameChars, "Project name"],
["generation_limit.notes_chars", "notes", notes.length, GENERATION_LIMITS.notesChars, "Notes"],
["generation_limit.audience_chars", "audience", audience.length, GENERATION_LIMITS.audienceChars, "Audience"],
["generation_limit.links_chars", "docs_url", researchUrl.length, GENERATION_LIMITS.linksChars, "Documentation links"],
["generation_limit.document_chars", "document_text", documentText.length, GENERATION_LIMITS.documentChars, "Document text"],
];
for (const [code, field, actual, max, label] of textChecks) {
if (actual > max) {
addLimit(generationLimitIssue({
code,
field,
actual,
max,
message: `${label} exceed the generation limit (${actual.toLocaleString()} / ${max.toLocaleString()} characters). Reduce this input before generating.`,
}));
}
}

const totalTextContextChars = notes.length + audience.length + researchUrl.length + documentText.length;
if (totalTextContextChars > GENERATION_LIMITS.totalTextContextChars) {
addLimit(generationLimitIssue({
code: "generation_limit.total_text_context_chars",
field: "context",
actual: totalTextContextChars,
max: GENERATION_LIMITS.totalTextContextChars,
message: `Combined text context exceeds the generation limit (${totalTextContextChars.toLocaleString()} / ${GENERATION_LIMITS.totalTextContextChars.toLocaleString()} characters). Shorten the brief, links, or document text.`,
}));
}

const countChecks = [
["generation_limit.links_count", "docs_url", urls.length, GENERATION_LIMITS.linksCount, "documentation links"],
["generation_limit.document_items", "document_text", documentItems.length, GENERATION_LIMITS.documentItems, "document items"],
["generation_limit.channels", "channels", channels.length, GENERATION_LIMITS.channels, "destination channels"],
["generation_limit.output_types", "output_types", outputTypes.length, GENERATION_LIMITS.outputTypes, "output types"],
["generation_limit.assets", "assets", assets.length, GENERATION_LIMITS.sourceRecordsPerKind, "assets"],
["generation_limit.source_artifacts", "source_artifacts", sourceArtifacts.length, GENERATION_LIMITS.sourceRecordsPerKind, "source artifacts"],
["generation_limit.processing_records", "processing_records", processingRecords.length, GENERATION_LIMITS.sourceRecordsPerKind, "processing records"],
["generation_limit.media_items", "media_items", mediaItems.length, GENERATION_LIMITS.mediaItems, "media items"],
];
for (const [code, field, actual, max, label] of countChecks) {
if (actual > max) {
addLimit(generationLimitIssue({
code,
field,
actual,
max,
message: `Use at most ${max} ${label} in one generation request; received ${actual}.`,
}));
}
}

if (!notes && !repo && !documentText) {
errors.push("You must provide at least one input context: a Description notes brief, a GitHub repo URL, or pasted document text.");
Expand All @@ -23,9 +100,7 @@ export function validateGenerationInputs(body = {}) {
errors.push("GitHub Repo must identify a public repository such as https://github.com/owner/repo.");
}

const researchUrl = normalizeTextInput(body?.research_url ?? body?.docs_url);
if (researchUrl) {
const urls = researchUrl.split(/\s+/).filter(Boolean);
urls.forEach((entry) => {
const candidate = /^https?:\/\//i.test(entry) ? entry : `https://${entry}`;
try {
Expand All @@ -42,5 +117,6 @@ export function validateGenerationInputs(body = {}) {
return {
valid: errors.length === 0,
errors,
limitIssues,
};
}
60 changes: 60 additions & 0 deletions frontend/lib/server/generationRequestBody.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
GENERATION_LIMITS,
generationLimitIssue,
} from "../package/generationLimits.mjs";

function byteLength(value) {
return new TextEncoder().encode(String(value || "")).byteLength;
}

function oversizedIssue(actual) {
return generationLimitIssue({
code: "generation_limit.request_bytes",
field: "request",
actual,
max: GENERATION_LIMITS.requestBytes,
message: `Generation request is too large (${actual} bytes). Keep the request at or below ${GENERATION_LIMITS.requestBytes} bytes.`,
});
}

export async function readGenerationRequestBody(request) {
const declared = Number(request?.headers?.get?.("content-length"));
if (Number.isFinite(declared) && declared > GENERATION_LIMITS.requestBytes) {
return Object.freeze({
ok: false,
status: 413,
code: "generation_limit_exceeded",
error: "Generation request exceeds the server input budget.",
issues: [oversizedIssue(declared)],
});
}

const raw = await request.text();
const actualBytes = byteLength(raw);
if (actualBytes > GENERATION_LIMITS.requestBytes) {
return Object.freeze({
ok: false,
status: 413,
code: "generation_limit_exceeded",
error: "Generation request exceeds the server input budget.",
issues: [oversizedIssue(actualBytes)],
});
}

try {
return Object.freeze({
ok: true,
status: 200,
body: raw ? JSON.parse(raw) : {},
actualBytes,
});
} catch {
return Object.freeze({
ok: false,
status: 400,
code: "invalid_json",
error: "Generation request body must be valid JSON.",
issues: [],
});
}
}
141 changes: 141 additions & 0 deletions frontend/tests/generationLimits.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";

import { GENERATION_LIMITS } from "../lib/package/generationLimits.mjs";
import { validateGenerationInputs } from "../lib/package/validatePackage.js";
import { readGenerationRequestBody } from "../lib/server/generationRequestBody.mjs";

function issueCodes(result) {
return new Set((result.limitIssues || []).map((issue) => issue.code));
}

test("generation body reader rejects declared oversized payload before JSON parsing", async () => {
const request = new Request("https://signalflow.test/api/launch_kit", {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": String(GENERATION_LIMITS.requestBytes + 1),
},
body: "{}",
});
const result = await readGenerationRequestBody(request);
assert.equal(result.ok, false);
assert.equal(result.status, 413);
assert.equal(result.code, "generation_limit_exceeded");
assert.equal(result.issues[0].code, "generation_limit.request_bytes");
assert.equal(result.issues[0].actual, GENERATION_LIMITS.requestBytes + 1);
});

test("generation body reader rejects actual oversized payload and malformed JSON", async () => {
const oversized = new Request("https://signalflow.test/api/launch_kit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notes: "x".repeat(GENERATION_LIMITS.requestBytes + 1) }),
});
const oversizedResult = await readGenerationRequestBody(oversized);
assert.equal(oversizedResult.status, 413);
assert.equal(oversizedResult.issues[0].code, "generation_limit.request_bytes");

const malformed = new Request("https://signalflow.test/api/launch_kit", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not-json",
});
const malformedResult = await readGenerationRequestBody(malformed);
assert.equal(malformedResult.ok, false);
assert.equal(malformedResult.status, 400);
assert.equal(malformedResult.code, "invalid_json");
});

test("generation validation returns stable field-specific limit codes", () => {
const cases = [
[
{ notes: "x".repeat(GENERATION_LIMITS.notesChars + 1) },
"generation_limit.notes_chars",
],
[
{ notes: "ok", docs_url: Array.from({ length: GENERATION_LIMITS.linksCount + 1 }, (_, index) => `https://example.com/${index}`).join(" ") },
"generation_limit.links_count",
],
[
{ notes: "ok", document_text: Array.from({ length: GENERATION_LIMITS.documentItems + 1 }, () => "doc") },
"generation_limit.document_items",
],
[
{ notes: "ok", document_text: ["x".repeat(GENERATION_LIMITS.documentChars + 1)] },
"generation_limit.document_chars",
],
[
{ notes: "ok", channels: Array.from({ length: GENERATION_LIMITS.channels + 1 }, (_, index) => `channel-${index}`) },
"generation_limit.channels",
],
[
{ notes: "ok", assets: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) },
"generation_limit.assets",
],
[
{ notes: "ok", source_artifacts: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) },
"generation_limit.source_artifacts",
],
[
{ notes: "ok", processing_records: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) },
"generation_limit.processing_records",
],
[
{ notes: "ok", media_items: Array.from({ length: GENERATION_LIMITS.mediaItems + 1 }, () => ({})) },
"generation_limit.media_items",
],
];

for (const [body, expectedCode] of cases) {
const result = validateGenerationInputs(body);
assert.equal(result.valid, false, expectedCode);
assert.ok(issueCodes(result).has(expectedCode), `missing ${expectedCode}`);
}
});

test("combined text context has its own budget", () => {
const body = {
notes: "n".repeat(35_000),
audience: "a".repeat(3_000),
docs_url: "https://example.com/" + "l".repeat(4_000),
document_text: ["d".repeat(119_000)],
};
const result = validateGenerationInputs(body);
assert.ok(issueCodes(result).has("generation_limit.total_text_context_chars"));
assert.equal(issueCodes(result).has("generation_limit.notes_chars"), false);
assert.equal(issueCodes(result).has("generation_limit.document_chars"), false);
});

test("exact individual generation limits remain accepted", () => {
const result = validateGenerationInputs({
notes: "n".repeat(GENERATION_LIMITS.notesChars),
channels: Array.from({ length: GENERATION_LIMITS.channels }, (_, index) => `channel-${index}`),
document_text: Array.from({ length: GENERATION_LIMITS.documentItems }, () => "doc"),
});
assert.equal(result.limitIssues.length, 0);
});

test("launch kit applies body and field limits before provider generation", async () => {
const route = await readFile(new URL("../app/api/launch_kit/route.js", import.meta.url), "utf8");
const readIndex = route.indexOf("readGenerationRequestBody(request)");
const validateIndex = route.indexOf("validateGenerationInputs(body)");
const generateIndex = route.indexOf("generateStudioPackage({");
assert.ok(readIndex >= 0);
assert.ok(validateIndex > readIndex);
assert.ok(generateIndex > validateIndex);
assert.match(route, /status: parsedRequest\.status/);
assert.match(route, /limitIssues: validation\.limitIssues/);
});

test("MCP generation schemas advertise the shared server ceilings", async () => {
const tools = await readFile(new URL("../../mcp/lib/tools.mjs", import.meta.url), "utf8");
assert.match(tools, /maxLength: GENERATION_LIMITS\.projectNameChars/);
assert.match(tools, /maxLength: GENERATION_LIMITS\.notesChars/);
assert.match(tools, /maxLength: GENERATION_LIMITS\.audienceChars/);
assert.match(tools, /maxLength: GENERATION_LIMITS\.linksChars/);
assert.match(tools, /maxItems: GENERATION_LIMITS\.channels/);
assert.match(tools, /maxItems: GENERATION_LIMITS\.documentItems/);
assert.match(tools, /maxItems: GENERATION_LIMITS\.sourceRecordsPerKind/);
});
Loading
Loading