diff --git a/solver-api/src/app.js b/solver-api/src/app.js index 6183c05..2ba6d51 100644 --- a/solver-api/src/app.js +++ b/solver-api/src/app.js @@ -13,6 +13,33 @@ const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml"); const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main"); const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main"); +function parseCorsOrigins(raw) { + if (!raw || typeof raw !== "string") return null; + const origins = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + return origins.length > 0 ? origins : null; +} + +function buildCorsOptions() { + const allow = parseCorsOrigins(process.env.CORS_ORIGINS); + if (!allow) return {}; + return { + origin(origin, cb) { + if (!origin || allow.includes(origin)) { + cb(null, true); + return; + } + cb(new Error("origin not allowed")); + } + }; +} + +function clientError(res, status, error, code = "bad_request") { + return res.status(status).json({ error, code, schemaVersion: 2 }); +} + function resolveSolverModel(raw) { const model = (raw || "fdm").toLowerCase(); if (model !== "fdm" && model !== "fea" && model !== "both") { @@ -196,7 +223,7 @@ function stableStringify(value) { export function buildApp() { const app = express(); - app.use(cors()); + app.use(cors(buildCorsOptions())); app.use(express.json({ limit: "4mb" })); app.get("/health", (_req, res) => { @@ -209,7 +236,7 @@ export function buildApp() { const parsed = await parseCaseXml(xml); res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) }); } catch (error) { - res.status(400).json({ error: String(error.message || error) }); + clientError(res, 400, String(error.message || error), "parse_failed"); } }); @@ -217,9 +244,7 @@ export function buildApp() { try { const xml = req.body?.xml; if (typeof xml !== "string" || !xml.trim()) { - return res - .status(400) - .json({ error: "Request body must include xml string", schemaVersion: 2 }); + return clientError(res, 400, "Request body must include xml string", "missing_xml"); } const parsed = await parseCaseXml(xml); return res.json({ @@ -228,9 +253,7 @@ export function buildApp() { fieldTraceability: buildFieldTraceability(parsed) }); } catch (error) { - return res - .status(400) - .json({ error: String(error.message || error), schemaVersion: 2 }); + return clientError(res, 400, String(error.message || error), "parse_failed"); } }); @@ -240,7 +263,7 @@ export function buildApp() { const qa = validateSurfaceCard(surfaceCard, req.body?.options || {}); return res.json({ ok: qa.ok, qa, schemaVersion: 2 }); } catch (error) { - return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); + return clientError(res, 400, String(error.message || error), "invalid_surface_card"); } }); @@ -248,13 +271,16 @@ export function buildApp() { try { const xml = req.body?.xml; if (!xml || typeof xml !== "string") { - return res.status(400).json({ error: "Request body must include xml string" }); + return clientError(res, 400, "Request body must include xml string", "missing_xml"); } const solverModel = resolveSolverModel(req.body?.solverModel); const workflow = resolveWorkflow(req.body?.workflow); const parsed = await parseCaseXml(xml); let surfaceCardQa = null; if (workflow === "diagnostic") { + if (!req.body?.surfaceCard) { + return clientError(res, 400, "diagnostic workflow requires surfaceCard data", "missing_surface_card"); + } surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard); } const runResults = await runRequestedModelsWithWorkflow( @@ -304,7 +330,7 @@ export function buildApp() { fingerprint }); } catch (error) { - return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); + return clientError(res, 400, String(error.message || error), "solve_failed"); } }); @@ -313,10 +339,12 @@ export function buildApp() { const solverModel = resolveSolverModel(_req.query?.solverModel); const workflow = resolveWorkflow(_req.query?.workflow); if (workflow === "diagnostic") { - return res.status(400).json({ - error: "diagnostic workflow requires POST /solve with surfaceCard data", - schemaVersion: 2 - }); + return clientError( + res, + 400, + "diagnostic workflow requires POST /solve with surfaceCard data", + "workflow_not_supported" + ); } const xml = fs.readFileSync(DEFAULT_XML, "utf-8"); const parsed = await parseCaseXml(xml); @@ -364,7 +392,7 @@ export function buildApp() { fingerprint }); } catch (error) { - return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); + return clientError(res, 400, String(error.message || error), "solve_failed"); } }); diff --git a/solver-api/src/solverClient.js b/solver-api/src/solverClient.js index b6c8f23..2e2d6f3 100644 --- a/solver-api/src/solverClient.js +++ b/solver-api/src/solverClient.js @@ -245,6 +245,19 @@ function runSolverProcess(solverBinaryPath, jsonPayload) { let stderr = ""; let outBytes = 0; let errBytes = 0; + let settled = false; + + const failOnce = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + + const resolveOnce = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); @@ -253,7 +266,7 @@ function runSolverProcess(solverBinaryPath, jsonPayload) { outBytes += Buffer.byteLength(chunk, "utf8"); if (outBytes > SOLVER_STDIO_MAX_BYTES) { child.kill("SIGKILL"); - reject(new Error("solver stdout exceeded max buffer")); + failOnce(new Error("solver stdout exceeded max buffer")); return; } stdout += chunk; @@ -263,20 +276,21 @@ function runSolverProcess(solverBinaryPath, jsonPayload) { errBytes += Buffer.byteLength(chunk, "utf8"); if (errBytes > SOLVER_STDIO_MAX_BYTES) { child.kill("SIGKILL"); - reject(new Error("solver stderr exceeded max buffer")); + failOnce(new Error("solver stderr exceeded max buffer")); return; } stderr += chunk; }); - child.on("error", (error) => reject(error)); + child.on("error", (error) => failOnce(error)); child.on("close", (code) => { + if (settled) return; if (code !== 0) { const detail = stderr.trim() || `solver exited with code ${code}`; - reject(new Error(detail)); + failOnce(new Error(detail)); return; } - resolve(stdout); + resolveOnce(stdout); }); child.stdin.write(jsonPayload); diff --git a/solver-api/src/xmlParser.js b/solver-api/src/xmlParser.js index 58e9057..bb2bf43 100644 --- a/solver-api/src/xmlParser.js +++ b/solver-api/src/xmlParser.js @@ -8,10 +8,16 @@ const PSI_TO_PA = 6894.757293168; const MPSI_TO_PA = 6.894757293168e9; function parseArrayValue(raw) { - return String(raw) - .split(":") - .filter(Boolean) - .map((item) => Number(item)); + const out = []; + for (const item of String(raw).split(":")) { + if (!item) continue; + const value = Number(item); + if (!Number.isFinite(value)) { + throw new Error(`Invalid numeric array value: ${item}`); + } + out.push(value); + } + return out; } function parseNumeric(raw) { diff --git a/solver-api/tests/api.test.js b/solver-api/tests/api.test.js index cf3e666..7d229ed 100644 --- a/solver-api/tests/api.test.js +++ b/solver-api/tests/api.test.js @@ -78,6 +78,23 @@ describe("solver-api", () => { expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true); }); + it("rejects /solve without xml and returns schema metadata", async () => { + const app = buildApp(); + const response = await request(app).post("/solve").send({}); + expect(response.status).toBe(400); + expect(response.body.schemaVersion).toBe(2); + expect(response.body.code).toBe("missing_xml"); + }); + + it("rejects diagnostic workflow without surface card", async () => { + const app = buildApp(); + const response = await request(app) + .post("/solve") + .send({ xml, solverModel: "fdm", workflow: "diagnostic" }); + expect(response.status).toBe(400); + expect(response.body.code).toBe("missing_surface_card"); + }); + it("is deterministic for the same input", async () => { const app = buildApp(); const a = await request(app).post("/solve").send({ xml });