← 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
M lib/utils.jsM routes/api.jsM tests/smoke.test.js
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 →