Skip to content

The last four rooms findings: a channel switch that was ignored, and a clash that named other people's shows - #95

Merged
MattA-Official merged 4 commits into
mainfrom
fix/review-rooms-3
Aug 25, 2026
Merged

MattA-Official merged 4 commits into
mainfrom
fix/review-rooms-3

Conversation

@MattA-Official

Copy link
Copy Markdown
Member

The last four review findings in this repo, one commit each. Gates: lint, typecheck, comments and build, all verified independently of the change author. No test suite here, so each path was exercised by hand against a local dev server on :3002 with both personas.

An admin who turned email off still received the fan-out. notifyAdmins filtered on the ADMIN_NEW_BOOKINGS type preference and then bcc'd everyone it had left, never reading notification_channels. So one column governed booking-update mail, which notifyBookingUpdate does check, and was ignored for new-request mail. It filters on both now.

The consequence is worth a committee view, and is why ADR-0010 exists. Push is recorded but never delivered in this repo, so an admin with email off now receives nothing at all. That is the honest reading of a switch headed "Where can we notify you?", and ADMIN_NEW_BOOKINGS remains the narrower opt-out for people who want other mail but not this. It does mean a committee can silence itself: if every admin turns email off, a request sits PENDING with only a log line. The /admin/bookings queue still shows it, so this is nobody being nudged rather than nobody being able to see it. The fan-out logs a warning when it resolves recipients but none take email. If the committee would rather this were operational mail that ignores the channel column, supersede ADR-0010 and the filter comes back out.

A booking clash named other members' productions. The 409 payload carried eventTitle for every conflicting booking, to any caller. It is allow-listed on the caller rather than stripped: conflictPayload emits eventTitle: 'Booked' unless the caller holds booking.read.any, which is what available.get.ts already did. The default is masked, so a route added later has to ask for titles rather than remember to remove them. No entry ever carried the user object, and the client types conflicts as { id: number }[], so no screen loses information.

The availability window was uncapped, so one request could load every booking in the table, on a query D1 bills by rows read. Two server-side bounds: the schema refuses a span over 31 days, and the sweep asks for 1001 rows and returns 400 if it gets more than 1000. It refuses rather than truncates, deliberately: the available/unavailable split is derived from those rows, so a dropped clash would offer an occupied room to the next member. The limit is an argument only the sweep passes, so the write gate and both availability checks still read every clash and the 409 stays complete.

The OpenAPI blocks declared a bare array for four list endpoints that have always returned { items, total, limit, offset }, and none declared the limit and offset parameters pageQuery accepts. Both corrected, with the bounds the schema enforces.

Two numbers are policy, not derived: 31 days and 1000 rows. They appear in the Zod schema, the route's OpenAPI block (which cannot read a constant, since defineRouteMeta is extracted statically), the API reference and ADR-0011. Change them together.

Closes #86, #88, #89, #90.

GET /api/bookings, /api/rooms, /api/venues and /api/users all return
{ items, total, limit, offset }, but their OpenAPI blocks still declared a
bare array. Someone generating a client from /_scalar got a type saying
Booking[], iterated the response, found nothing to iterate and rendered an
empty list with no error explaining it.

The same blocks omitted limit and offset from their query parameters
although every one of the four accepts them, so the request was
misdescribed as well as the response. Both are now declared, with the
bounds validation.ts enforces.
notifyAdmins filtered on the ADMIN_NEW_BOOKINGS preference and then bcc'd
everyone it had left, without ever reading notification_channels. An admin
who turned the Email switch off on the settings page stopped receiving
their own booking mail, because notifyBookingUpdate does read the column,
and carried on receiving every new booking request and every member
cancellation. One column governed one class of mail and was ignored for
the other.

It now filters on both. Push delivers nothing here, so an admin with email
off receives no fan-out at all; the decision record explains why the
switch is authoritative rather than advisory, and the fan-out logs a
warning when it leaves nobody to tell, so a request nobody saw shows up in
the log instead of vanishing.

The settings page said the Email switch covered booking updates. It covers
the admin fan-out too, and now says so.
GET /api/rooms/available checked only that endTime came after startTime,
then read every overlapping booking joined to its user row with no limit.
Asking for the year 2000 to the year 2100 pulled the whole bookings table
into one Worker isolate, and any signed-in member could ask. D1 bills by
rows read, so this was paid for on every call, and it grows into the
booking form losing the check it depends on.

Two bounds now. The schema refuses a window spanning more than 31 days,
which no booking form can produce because both ends come from one event
date. The sweep asks for at most 1001 clashing rows and refuses with a 400
over 1000, rather than answering from a truncated set: the split between
available and unavailable rooms is derived from those rows, so a dropped
clash would offer an occupied room to the next member. The limit is passed
only by the sweep, so the occupancy gate on the write path still sees
every clash.
The 409 a clashing write returns listed each conflicting booking with its
real event title, whoever asked. GET /api/rooms/available has masked that
field as "Booked" for anyone without booking.read.any since the permission
split landed, and the same reasoning applies here: the owner of a pending
request that already holds a room reaches the same payload by nudging
their own times, and could step a window through an evening to learn which
production has the room and when.

The payload is now allow-listed on the caller, not stripped: an admin
still gets the titles that make the error worth reading, and everyone else
gets "Booked" with the id, window and status they need to pick another
slot. The option defaults to masked, so a route added later has to ask for
the titles rather than leak them by omission.
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.

OpenAPI blocks still declare a bare array for endpoints that return a pagination envelope

1 participant