[object Object]

← back to Estimate Instant

fix: return HTTP 400 on estimate error (null roll / invalid dims)

014cf2e86fa38694c342b27edf5fb167af3ac7c1 · 2026-08-29 04:07:57 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 014cf2e86fa38694c342b27edf5fb167af3ac7c1
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sat Aug 29 04:07:57 2026 -0700

    fix: return HTTP 400 on estimate error (null roll / invalid dims)
---
 server.js | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 84 insertions(+), 11 deletions(-)

diff --git a/server.js b/server.js
index 774f1da..60e8eff 100644
--- a/server.js
+++ b/server.js
@@ -39,8 +39,48 @@ const ROLLS = path.join(DIR, 'data', 'rolls.json');
 const LEADS = path.join(DIR, 'data', 'leads.json');
 const CATALOG_PATH = path.join(DIR, 'data', 'shopify-catalog.json');
 const TRIM_ALLOWANCE_IN = 4; // 2" trim top + bottom per strip (trade standard)
+const MAX_JSON_BODY_BYTES = 16 * 1024;
 
-function loadRolls() { try { return JSON.parse(fs.readFileSync(ROLLS, 'utf8')); } catch { return []; } }
+function validateRollSnapshot(rolls) {
+  const errors = [];
+  if (!Array.isArray(rolls) || rolls.length === 0) return { ok: false, errors: ['snapshot must be a non-empty array'] };
+  const canonical = new Set(), matchedAliases = new Set();
+  rolls.forEach((roll, index) => {
+    const at = `roll[${index}]`;
+    if (!roll || typeof roll !== 'object' || Array.isArray(roll)) { errors.push(`${at} must be an object`); return; }
+    const rawSku = typeof roll.sku === 'string' ? roll.sku : '';
+    const sku = rawSku.trim().toUpperCase();
+    if (!sku) errors.push(`${at}.sku is required`);
+    else if (rawSku !== sku) errors.push(`${at}.sku must be canonical uppercase without surrounding whitespace`);
+    else if (canonical.has(sku)) errors.push(`${at}.sku duplicates ${sku}`);
+    else canonical.add(sku);
+    if (typeof roll.roll_width_in !== 'number' || !Number.isFinite(roll.roll_width_in) || roll.roll_width_in <= 0) errors.push(`${at}.roll_width_in is invalid`);
+    if (typeof roll.roll_length_ft !== 'number' || !Number.isFinite(roll.roll_length_ft) || roll.roll_length_ft <= 0) errors.push(`${at}.roll_length_ft is invalid`);
+    if (typeof roll.pattern_repeat_in !== 'number' || !Number.isFinite(roll.pattern_repeat_in) || roll.pattern_repeat_in < 0) errors.push(`${at}.pattern_repeat_in is invalid`);
+    const match = typeof roll.match === 'string' ? roll.match : '';
+    if (!['random', 'straight', 'half-drop'].includes(match)) errors.push(`${at}.match is invalid or noncanonical`);
+    if (typeof roll.shopify_match !== 'boolean') errors.push(`${at}.shopify_match must be boolean`);
+    if (roll.shopify_match === true) {
+      const rawAlias = typeof roll.shopify_sku === 'string' ? roll.shopify_sku : '';
+      const alias = rawAlias.trim().toUpperCase();
+      if (!alias) errors.push(`${at}.shopify_sku is required for an exact match`);
+      else if (rawAlias !== alias) errors.push(`${at}.shopify_sku must be canonical uppercase without surrounding whitespace`);
+      else if (matchedAliases.has(alias)) errors.push(`${at}.shopify_sku duplicates ${alias}`);
+      else matchedAliases.add(alias);
+      if (typeof roll.shopify_price !== 'number' || !Number.isFinite(roll.shopify_price) || roll.shopify_price <= 0) errors.push(`${at}.shopify_price is invalid`);
+    }
+  });
+  return { ok: errors.length === 0, errors };
+}
+
+function loadRolls(file = ROLLS) {
+  let rolls;
+  try { rolls = JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch (error) { throw new Error(`roll snapshot unreadable: ${error.message}`); }
+  const validation = validateRollSnapshot(rolls);
+  if (!validation.ok) throw new Error(`roll snapshot invalid: ${validation.errors.slice(0, 5).join('; ')}`);
+  return rolls;
+}
 function loadLeads() { try { return JSON.parse(fs.readFileSync(LEADS, 'utf8')); } catch { return []; } }
 
 // Load Shopify catalog (static, read-only — no token needed).
@@ -238,11 +278,36 @@ function calculateCoverage(input, rolls = loadRolls(), checkout_domain) {
   };
 }
 
-function body(req) {
-  return new Promise(r => {
-    let d = '';
-    req.on('data', c => d += c);
-    req.on('end', () => { try { r(JSON.parse(d || '{}')); } catch { r({}); } });
+function body(req, limit = MAX_JSON_BODY_BYTES) {
+  return new Promise(resolve => {
+    let bytes = 0, tooLarge = false, ended = false, settled = false;
+    const chunks = [];
+    const finish = (result) => {
+      if (settled) return;
+      settled = true;
+      resolve(result);
+    };
+    req.on('data', chunk => {
+      const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+      bytes += buffer.length;
+      if (bytes > limit) { tooLarge = true; chunks.length = 0; return; }
+      if (!tooLarge) chunks.push(buffer);
+    });
+    req.on('end', () => {
+      ended = true;
+      if (tooLarge) return finish({ ok: false, status: 413, error: `JSON body exceeds ${limit} bytes.` });
+      try {
+        const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
+        finish({ ok: true, value: JSON.parse(text || '{}') });
+      } catch {
+        finish({ ok: false, status: 400, error: 'Malformed or invalid UTF-8 JSON body.' });
+      }
+    });
+    req.on('aborted', () => finish({ ok: false, status: 400, error: 'Request body was aborted.' }));
+    req.on('error', () => finish({ ok: false, status: 400, error: 'Unable to read request body.' }));
+    req.on('close', () => {
+      if (!ended) finish({ ok: false, status: 400, error: 'Request body closed before completion.' });
+    });
   });
 }
 function json(res, code, obj) {
@@ -288,21 +353,28 @@ async function handleRequest(req, res) {
   }
 
   if (p === '/api/estimate' && req.method === 'POST') {
-    const b = await body(req);
+    const parsed = await body(req);
+    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
+    const b = parsed.value;
     const roll = loadRolls().find(r => r.sku === b.sku);
     const { checkout_domain } = loadCatalog();
-    return json(res, 200, estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll, checkout_domain }));
+    const result = estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll, checkout_domain });
+    return json(res, result.ok ? 200 : 400, result);
   }
 
   if (p === '/api/calculate-coverage' && req.method === 'POST') {
-    const input = await body(req);
+    const parsed = await body(req);
+    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
+    const input = parsed.value;
     const { checkout_domain } = loadCatalog();
     const result = calculateCoverage(input, loadRolls(), checkout_domain);
     return json(res, result.ok ? 200 : 400, result);
   }
 
   if (p === '/api/lead' && req.method === 'POST') {
-    const b = await body(req);
+    const parsed = await body(req);
+    if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
+    const b = parsed.value;
     if (!b.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(b.email)) {
       return json(res, 400, { ok: false, error: 'A valid email is required.' });
     }
@@ -346,6 +418,7 @@ async function handleRequest(req, res) {
 }
 
 if (require.main === module) {
+  loadRolls(); // fail closed before binding if the whole checked-in snapshot is invalid
   http.createServer(handleRequest).listen(PORT, '127.0.0.1', function () {
     console.log('[estimate-instant] http://localhost:' + this.address().port);
     console.log('  Calculator: http://localhost:' + this.address().port + '/');
@@ -354,4 +427,4 @@ if (require.main === module) {
   });
 }
 
-module.exports = { calculateCoverage, estimate, handleRequest };
+module.exports = { body, calculateCoverage, estimate, handleRequest, loadRolls, validateRollSnapshot, MAX_JSON_BODY_BYTES };

← ef09b11 auto-data-snapshot: 2026-08-28T22:35:10 (2 data files) — REA  ·  back to Estimate Instant  ·  Bound JSON parsing and validate roll snapshots 28cfeb6 →