Repository navigation
Fourteen medium findings: a stale admin bcc list, an unheld slot, and two half-run write sequences - #93
Merged
Conversation
An uptime monitor that cache-busts its request, GET /api/health?cb=123, was answered 401 by the session guard instead of the documented 200 or 503. event.path carries the query string, and the allowlist entry for the health route is anchored at the end, so the pattern never matched. A monitor configured to alarm only on 5xx read that 401 as healthy, which would have hidden the 503 that a schema-behind-code deploy returns. The middleware now strips the query once, before testing any pattern, which is the idiom the other three repos already use, and the health entry also accepts the trailing-slash form that routes to the same handler. Recorded in the API reference so whoever sets the monitor up next knows the parameter is safe.
Erasure empties the two notification columns, and that is the only thing keeping the fan-out away from a scrubbed row. A sealed session stays readable for up to thirty days after erasure, so an erased member could open /settings/notifications, flick a switch, and refill both columns. The row then no longer reflected the erasure, and shouldNotify started answering true for it again. Nothing was delivered, because the scrubbed address ends in .invalid and sendEmail drops those, but the estate rule is that an anonymised row is never written back over, whichever caller asks. The update now carries the same anonymised_at IS NULL predicate the mirror upsert has, and answers 409 "Account erased" when it matches nothing, matching POST /api/users. The data model and the API reference say so.
An owner may cancel a confirmed booking but not edit one. The route checked only the status when it saw a cancellation, and the owner schema accepts eventTitle, numberOfAttendees, notes, startTime and endTime alongside it, so a member could send status CANCELLED with a new window and have the lot written: the PENDING-only guard is on the other branch, and a cancelled booking holds no slot so no occupancy check runs. The admin alert that tells the committee to ring an external venue and release the slot was then built from the rewritten row, so it named times the member had just invented and there was no untampered record left to check it against. The rule now lives in the schema, where the machine-readable contract is: a body carrying status alongside any other key is a 400. The route also narrows the patch to the status alone, so the write cannot widen again if the schema is relaxed. API reference updated.
Rejecting a twelve-week recurring request from the admin page sends ?scope=series, which moves every open occurrence, but the notification was built from the single booking in the URL. The member was told "Your booking (Mon, 15 Jan 2026 at 2:00 pm - 4:00 pm) has been rejected", and believed they still held the other eleven. bookingStatusMessage now takes the number of occurrences the change covered and names it, so all five statuses gain the same series wording rather than one route growing a string of its own. The count is the server's resolved target list, so a series whose other occurrences are already closed still reads as a single date. Recorded in the API reference and struck from the README's known gaps.
Two write sequences could half-run and leave a row holding a room that nobody knew about. Deleting one occurrence of a series ran three promotion updates and then the delete, as four separate statements, and told the member it was cancelled before any of them. A failure part-way left the booking alive and still holding its slot while the member had already been told to stop turning up, and a promotion that died between detaching the successor and repointing its siblings split the series in two, so no later scope=series delete could clear both halves. The promotion and the delete now go in one db.batch, and the member is told afterwards, which is the order the bulk route already used. Creating a recurring booking wrote the head, then the pattern, then the occurrences, and only the last of those was atomic. A failed occurrence batch left the head committed, unannounced and holding its slot, so the admin's retry came back as "1 of 6 occurrence(s) clash" against a booking they had never seen. The pattern and the occurrences now go in one batch, and a failure deletes the head again rather than leaving it behind. Data model and API reference updated.
Availability was decided by a SELECT and applied by a separate UPDATE whose WHERE bound the booking id and nothing else, so nothing held the slot in between. Two admins triaging the Monday queue could each confirm a different pending request into Rehearsal Room 3 for the same evening: both checks saw the room free, both writes succeeded, and both casts were emailed a confirmation. Nothing in either row recorded the clash, so it surfaced when they both turned up. D1 has no interactive transaction, so there was no way to close it by wrapping the pair. The UPDATE now carries the occupancy rule as a NOT EXISTS predicate, built by the same planner every write path already goes through, so a route cannot skip it. It is attached only when the resolved status actually holds a slot, is skipped for the allowConflicts override, matches whichever of room or external venue was assigned, and binds a fixed number of parameters. A write that matches nothing is separated into a 404 for a booking that has gone and the ordinary 409, with its list of clashing bookings, for one that lost the race. ADR-0008 records the reasoning, including what a mid-batch clash leaves behind under scope=series, and the data model and API reference point at it.
SQLite creates no index for a foreign key, so bookings.user_id and bookings.external_venue_id had none. Every member-facing read filters on user_id: the stats endpoint counts over it four times, the list endpoint scopes a non-admin to their own rows, /requests walks every page of that, and four of the GDPR hooks do the same. Loading the member home page was five full scans of bookings. checkVenueAvailability was worse in kind, since it is the occupancy gate for every external-venue assignment and had nothing to lean on, unlike the room check which the (room_id, start_time, end_time) index already covers. Bookings are only ever added and D1 bills by rows read, so this got worse every term and never better. Adds (user_id, start_time) and (external_venue_id, start_time, end_time). The generated migration is two CREATE INDEX statements, no table rebuild, and the data model's index section now lists all five and says why the foreign keys needed their own.
users.is_rooms_admin is refreshed from the session, but only while that person keeps using this app. rooms:ADMIN carries a committee-year expiry, so the whole outgoing committee loses the role on the same day and has no reason to sign in here again. Their mirror rows kept the flag set for good, and every new booking request went on being bcc'd to them, carrying the requester's name, the event title, the date, the attendee count and their free-text notes. Nothing in the app or in stage-door could have cleared it: role changes are not pushed to consumer apps, and a committee-year lapse has no revocation event to push. The fan-out now resolves its recipients from stage-door's role-holders endpoint, which excludes an expired grant, and intersects the ids against the local mirror in chunks so it still has their notification preferences to respect. The answer is cached for ten minutes per isolate; if stage-door cannot be reached the cached flag is used and a warning logged, because sending nothing means a booking request nobody sees. proscenium already consumes the same endpoint the same way. The lookup and the preference filter existed twice, in notifications.ts and inline in POST /api/bookings. There is now one, so the next change to it cannot miss half. ADR-0009 records the reasoning; the data model no longer claims the column is refreshed on each request.
…take The bulk Reject button sent the literal "Bulk rejection" as the reason, so every member in the selection was emailed "Your booking ... has been rejected: Bulk rejection". That string was also written to bookings.rejection_reason, so it is what they saw on /requests afterwards and what a subject access export returned. The mandatory-reason rule in the schema exists because the reason is shown to the requester, and a placeholder satisfied the rule while defeating the point. The single-booking path has always prompted for one. Adds a bulk variant of the reject modal, taking the number of bookings and emitting only the reason, with the same non-empty guard the single modal has. The reason is then interpolated per booking exactly as the single path does, so no server change was needed. The button beside it disagreed with the action as well: its condition and its count both looked only at PENDING while the handler also takes AWAITING_EXTERNAL, so a selection of purely awaiting-external bookings hid the button and a mixed selection rejected more than the badge said. Both now read the same list.
useRoomAvailability caught every error, logged it and emptied both lists. The booking forms read empty lists as "the check has not run yet" and fell back to the plain room list, whose entries carry no conflicts, so every room computed as available and was offered unbadged. totalUnavailable was zero, so the "Show unavailable" button disappeared too, and the admin had no way to tell "nothing clashes" from "we could not check". The usual trigger is the session crossing the fifteen-minute staleness line, where the endpoint answers 401. The composable now keeps an explicit error and a hasAvailability flag, so a caller can tell a failed check from one that has not run. Both forms surface the error through the shared toast and offer no rooms while it stands, with a line in the picker saying why. The server still refuses a clashing write, so this was a misleading picker rather than a double booking, but a picker that quietly lies is worse than one that says it does not know. Recorded in the README that a 401 carrying stale:true from a fetch is surfaced rather than refreshed: only route navigation refreshes the session, and deciding what an interceptor should do to a half-filled form is its own change.
This was referenced Aug 25, 2026
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourteen medium findings, in ten commits grouped by cause. Gates: lint, typecheck, comments, build. No test suite here, so each change was reasoned against the code and the affected paths driven by hand.
The outgoing committee kept receiving booking requests forever.
users.is_rooms_adminis refreshed from the session, but only while that person keeps using this app.rooms:ADMINcarries a committee-year expiry, so the whole committee loses the role on one day and has no reason to sign in here again: their mirror rows kept the flag set for good, and every new request went on being bcc'd to them with the requester's name, the event title, the date, the attendee count and their free-text notes. Nothing in either app could have cleared it, since role changes are not pushed to consumers and a committee-year lapse has no revocation event to push. The fan-out now resolves recipients from stage-door's role-holders endpoint, intersecting against the local mirror in chunks so it still honours notification preferences, cached ten minutes per isolate and falling back to the stale flag with a warning if stage-door is unreachable, because sending nothing means a booking request nobody sees. proscenium already consumes that endpoint the same way. The lookup existed twice, innotifications.tsand inline inPOST /api/bookings; there is one now.A slot was decided by a SELECT and applied by an UPDATE that bound only the booking id, so nothing held it in between. Two admins triaging the Monday queue could each confirm a different request into Rehearsal Room 3 for the same evening: both checks saw it free, both writes succeeded, both casts were emailed a confirmation, and nothing in either row recorded the clash, so it surfaced when they both turned up. The UPDATE now carries the occupancy rule as a
NOT EXISTSpredicate built by the same planner every write path goes through, binding a fixed number of parameters. ADR-0008 records what a mid-batch clash still leaves behind under?scope=series.Two write sequences could half-run and leave a row holding a room nobody knew about: deleting one occurrence of a series ran three promotions and a delete as four statements after telling the member it was cancelled, and a recurring create wrote the head, then the pattern, then the occurrences, so a failed occurrence batch left the head committed, unannounced, and holding its slot. Both land in one
db.batchnow.An owner could edit a confirmed booking by cancelling it. The route checked only the status on the cancellation branch, and the owner schema accepts
startTime,endTime,eventTitleand the rest alongside it, so a member could sendCANCELLEDwith a new window and have the lot written. The admin alert telling the committee to ring an external venue was then built from the rewritten row. The rule now lives in the schema, and the route narrows the patch to the status alone so the write cannot widen again if the schema is relaxed.Two things members were told that were not true. The bulk Reject button sent the literal string "Bulk rejection" as the reason, which was emailed to every member in the selection, written to
rejection_reason, shown on/requestsand returned by a subject access export; the mandatory-reason rule exists because the reason is read by the requester, and a placeholder satisfied the rule while defeating it. And rejecting a twelve-week recurring request sends?scope=seriesbut built its notification from the single booking in the URL, so the member was told one date had been rejected and believed they still held the other eleven.A failed availability check read as "every room is free". The composable emptied both lists on error; the forms read empty as "not run yet" and fell back to the plain room list, whose entries carry no conflicts, so every room computed available and unbadged, and
totalUnavailablewas zero so the "Show unavailable" button vanished too. The usual trigger is the session crossing the fifteen-minute staleness line. The server still refuses a clashing write, so this was a lying picker rather than a double booking.Erased rows could refill. A sealed session stays readable for up to thirty days after erasure, so an erased member could open
/settings/notifications, flick a switch and rewrite the two columns that keep the fan-out away from a scrubbed row.Performance: SQLite creates no index for a foreign key, so
bookings.user_idandexternal_venue_idhad none, and loading the member home page was five full scans. Two indexes added; D1 bills by rows read and bookings are only ever added, so this got worse every term.