Skip to content

fix(api): honour caller-supplied time bounds in event range queries#381

Merged
ridafkih merged 1 commit into
ridafkih:mainfrom
agurod42:fix/event-range-bounds
May 23, 2026
Merged

fix(api): honour caller-supplied time bounds in event range queries#381
ridafkih merged 1 commit into
ridafkih:mainfrom
agurod42:fix/event-range-bounds

Conversation

@agurod42
Copy link
Copy Markdown
Contributor

Summary

normalizeDateRange in services/api/src/utils/date-range.ts silently widens any narrow date range to a full server-local day, discarding the time component the caller passed. As a result, GET /api/events?from=…&to=… (and the MCP get_events tool that wraps it) can return events that fall hours outside the requested window.

Why

normalizeDateRange is the last hop before getEventsInRange builds its SQL BETWEEN clause:

const normalizeDateRange = (from: Date, to: Date): NormalizedDateRange => {
  const start = new Date(from);
  start.setHours(HOURS_START_OF_DAY, MINUTES_START, SECONDS_START, MILLISECONDS_START);
  const end = new Date(to);
  end.setHours(HOURS_END_OF_DAY, MINUTES_END, SECONDS_END, MILLISECONDS_END);
  return { end, start };
};

The setHours calls mutate the input to start-of-day / end-of-day in the server's local timezone, regardless of what the caller wrote. So a query like:

GET /api/events?from=2026-05-18T03:00:00Z&to=2026-05-19T02:59:59Z

becomes the SQL range [2026-05-18 00:00 (server-local), 2026-05-19 23:59 (server-local)] — depending on the server's TZ, that can pull in an event at 2026-05-19T14:00:00Z, which is hours past the upper bound the caller wrote.

The web frontend hides this because applications/web/src/hooks/use-events.ts already passes day-shaped bounds:

to.setDate(to.getDate() + DAYS_PER_PAGE - 1);
to.setHours(23, 59, 59, 999);

So the snap was a no-op for the web client and effectively a footgun for any other caller. The MCP get_events tool, which passes precise UTC instants, is the obvious one — once the MCP is shipped to public clients, callers will pass everything from minute-precise to date-only inputs.

Changes

Single file: services/api/src/utils/date-range.ts.

Drops the setHours mutations. normalizeDateRange now just clones the inputs so the caller's Date objects don't get mutated downstream:

const normalizeDateRange = (from: Date, to: Date): NormalizedDateRange => ({
  start: new Date(from),
  end: new Date(to),
});

parseDateRangeParams keeps its current defaults (from = now, to = from + 1 week) since those are useful when callers omit the params entirely.

Testing

Added services/api/tests/utils/date-range.test.ts covering:

  • the regression: from=2026-05-18T03:00:00Z, to=2026-05-19T02:59:59Z round-trips unchanged (was widening to end-of-day local before)
  • normalizeDateRange returns fresh Date instances (callers may mutate)
  • parseDateRangeParams honours exact ISO instants
  • parseDateRangeParams defaults to to one week after from when only from is supplied

Verified the web flow still works against an ICS-only feed: pagination keys stay day-shaped, results match what the calendar UI showed before.

Compatibility

  • The web frontend behavior is unchanged because it already supplies day-shaped bounds via setHours(23,59,59,999).
  • Any external caller that was relying on the silent widen-to-whole-day behavior will now get strictly what they asked for. The fix is the more conservative interpretation; if a caller wants a full day they can pass T00:00:00Z / T23:59:59Z explicitly.

Out of scope

  • Auditing other queries that might inherit the same pattern. Spot-checked services/api/src/routes/api/v1/calendars/[calendarId]/invites.ts and services/api/src/routes/api/events/index.ts; both forward whatever parseDateRangeParams returns and benefit from the same fix.

`normalizeDateRange` was snapping `from` to start-of-day and `to` to
end-of-day in server-local time, discarding any non-midnight time
component the caller passed. A query like
  to=2026-05-19T02:59:59Z
got widened to "end of Tuesday in server-local time", letting an event
at 2026-05-19T14:00:00Z slip through even though the upper bound was
hours earlier.

Drop the `setHours` calls; pass the instants through. The Web frontend
already supplies day-shaped bounds (see use-events.ts), so removing the
snap is a no-op for it. The MCP `get_events` path, which passes precise
UTC instants, now returns events strictly within the requested window.

Adds unit tests covering both the regression and the parsing defaults.
Comment on lines +39 to +45
* Honour the exact instants supplied by the caller. Previously this snapped
* `from` to start-of-day and `to` to end-of-day in server-local time, which
* silently widened queries whose bounds carried a meaningful time component
* (e.g. MCP callers passing precise UTC instants). The web frontend already
* supplies day-shaped bounds (see hooks/use-events.ts), so removing the
* snap is a no-op for it.
*/
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just for future reference, but I'd appreciate it if PR-specific context (this used to...) was trimmed in the comments for brevity. I know agents tend to like to overexplain in the comments, but the context loses its meaning once the offending code is omitted.

@ridafkih ridafkih merged commit a8dc206 into ridafkih:main May 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants