[object Object]

← back to NationalPaperHangers

Validate availability time windows instead of silently breaking the calendar

da5acf043e0754075985a338d7548a053a376621 · 2026-05-18 20:10:58 -0700 · SteveStudio2

POST /admin/availability checked only that day_of_week was 0-6 and that
the time fields were non-empty — unlike POST /admin/time-off right below
it, which validates dates and ordering. So an installer could save an
availability window with a malformed time (e.g. "99:99"), which 500s on
the ::time-cast INSERT, or an inverted one (start_time 17:00 >
end_time 09:00), which saves cleanly with a 201 and shows in the
calendar UI but produces zero bookable slots: slots.js builds an invalid
luxon Interval and silently drops the whole window, so the installer
gets no bookings and no error explaining why.

A clockMinutes() helper in lib/utils.js parses "H:MM" / "HH:MM" /
"HH:MM:SS" to minute-of-day (or null when malformed); the route now
rejects malformed input with 400 invalid_time and inverted windows with
400 invalid_time_range. Unit-tested across valid forms, every malformed
shape, and ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit da5acf043e0754075985a338d7548a053a376621
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 20:10:58 2026 -0700

    Validate availability time windows instead of silently breaking the calendar
    
    POST /admin/availability checked only that day_of_week was 0-6 and that
    the time fields were non-empty — unlike POST /admin/time-off right below
    it, which validates dates and ordering. So an installer could save an
    availability window with a malformed time (e.g. "99:99"), which 500s on
    the ::time-cast INSERT, or an inverted one (start_time 17:00 >
    end_time 09:00), which saves cleanly with a 201 and shows in the
    calendar UI but produces zero bookable slots: slots.js builds an invalid
    luxon Interval and silently drops the whole window, so the installer
    gets no bookings and no error explaining why.
    
    A clockMinutes() helper in lib/utils.js parses "H:MM" / "HH:MM" /
    "HH:MM:SS" to minute-of-day (or null when malformed); the route now
    rejects malformed input with 400 invalid_time and inverted windows with
    400 invalid_time_range. Unit-tested across valid forms, every malformed
    shape, and ordering.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/utils.js        | 16 +++++++++++++++-
 routes/api.js       |  9 +++++++++
 tests/smoke.test.js | 27 +++++++++++++++++++++++++++
 3 files changed, 51 insertions(+), 1 deletion(-)

diff --git a/lib/utils.js b/lib/utils.js
index ab74599..dc6d98e 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -26,4 +26,18 @@ function clampLen(v, max) {
   return s.length > max ? s.slice(0, max) : s;
 }
 
-module.exports = { escapeHtml, safeHttpUrl, clampLen };
+// Parse a wall-clock time string ("H:MM", "HH:MM", or "HH:MM:SS") to its
+// minute-of-day as a number, or null when malformed. Used to validate
+// installer availability windows before they reach a `::time`-cast INSERT:
+// a garbage value would 500 the query, and an inverted start/end pair would
+// save cleanly but produce zero bookable slots (slots.js drops the invalid
+// Interval), leaving the installer with no bookings and no error.
+function clockMinutes(v) {
+  const m = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(String(v == null ? '' : v).trim());
+  if (!m) return null;
+  const h = Number(m[1]);
+  if (h > 23) return null;
+  return h * 60 + Number(m[2]) + (m[3] ? Number(m[3]) / 60 : 0);
+}
+
+module.exports = { escapeHtml, safeHttpUrl, clampLen, clockMinutes };
diff --git a/routes/api.js b/routes/api.js
index d4cf608..e2a73bf 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -11,6 +11,7 @@ const bookingToken = require('../lib/booking-token');
 const roomCapturesLib = require('../lib/room-captures');
 const mediaSniff = require('../lib/media-sniff');
 const stripe = require('../lib/stripe');
+const { clockMinutes } = require('../lib/utils');
 const { requireInstaller } = require('../lib/auth');
 const router = express.Router();
 
@@ -328,6 +329,14 @@ router.post('/admin/availability', requireInstaller, async (req, res, next) => {
     const dow = parseInt(day_of_week, 10);
     if (!(dow >= 0 && dow <= 6)) return res.status(400).json({ error: 'invalid_day' });
     if (!start_time || !end_time) return res.status(400).json({ error: 'missing_time' });
+    // Reject malformed or inverted windows. A garbage time string would 500
+    // the ::time-cast INSERT; an inverted start/end would save cleanly but
+    // slots.js silently drops the invalid Interval, so the installer would
+    // get zero bookable slots with no error to explain why.
+    const startMin = clockMinutes(start_time);
+    const endMin   = clockMinutes(end_time);
+    if (startMin === null || endMin === null) return res.status(400).json({ error: 'invalid_time' });
+    if (endMin <= startMin) return res.status(400).json({ error: 'invalid_time_range' });
     const r = await db.one(
       `INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time)
        VALUES ($1, $2, $3::time, $4::time)
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 21266d0..ef78fae 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -254,6 +254,33 @@ test('slots: feed availability + time-off + booking → correct openings count &
   }
 });
 
+// ─────────────────────────────────────────────────────────────────────────────
+// 5b. clockMinutes — availability time-string validation
+//     Guards POST /admin/availability: a malformed time 500s the ::time-cast
+//     INSERT, and an inverted window saves but yields zero bookable slots.
+// ─────────────────────────────────────────────────────────────────────────────
+test('clockMinutes parses valid times and rejects malformed/inverted input', () => {
+  const { clockMinutes } = require('../lib/utils');
+
+  // Valid forms → minute-of-day
+  assert.equal(clockMinutes('09:00'), 540);
+  assert.equal(clockMinutes('9:00'), 540);          // single-digit hour
+  assert.equal(clockMinutes('17:30'), 1050);
+  assert.equal(clockMinutes('00:00'), 0);
+  assert.equal(clockMinutes(' 08:15 '), 495);       // surrounding whitespace
+  assert.equal(clockMinutes('23:59:30'), 23 * 60 + 59 + 0.5);
+
+  // Malformed → null (these would 500 the ::time-cast INSERT)
+  for (const bad of ['', '   ', 'abc', '99:99', '24:00', '12:60',
+                     '12', '12:5', '-1:00', '12:00:99', '1200', null, undefined]) {
+    assert.equal(clockMinutes(bad), null, `${JSON.stringify(bad)} should parse to null`);
+  }
+
+  // An inverted window must be detectable as start >= end
+  assert.ok(clockMinutes('17:00') > clockMinutes('09:00'));
+  assert.ok(clockMinutes('09:00') < clockMinutes('09:30'));
+});
+
 // ─────────────────────────────────────────────────────────────────────────────
 // 6. Claim token: generate, verify, consume, second-use fails
 // ─────────────────────────────────────────────────────────────────────────────

← 3fa1e62 Coerce garbage numeric booking fields to null instead of 500  ·  back to NationalPaperHangers  ·  Carry the IDOR-gate token on the customer's booking-confirma 362b0cc →