Skip to content

Latest commit

 

History

History
269 lines (205 loc) · 17.4 KB

api-reference.md

File metadata and controls

269 lines (205 loc) · 17.4 KB

API reference

Every route under server/api/. Response codes are also declared in each handler's defineRouteMeta({ openAPI: … }) block, which is the machine-readable contract; this document carries the behaviour that block cannot express.

Auth

Helper Effect
requireAuth(event) 401 unless there is a valid session
requireAdmin(event) 401 unless signed in; 401 with { stale: true } if the session's roles are older than 15 minutes, which the client middleware turns into a refresh; 403 unless the session carries rooms:ADMIN
requireHookAuth(event) Bearer must be the SHA-256 of this app's service token. Used only by the _hooks routes, which the auth service calls.
canNow(event, permission) A permission claim, staleness-checked on the same 15-minute rule as requireAdmin. Someone who does not hold the permission gets false, not a refresh prompt.

Every permission claim is staleness-checked, whether the route guards itself with requireAdmin or asks canNow for one permission. A role revoked centrally stops working within 15 minutes on every route, rather than only on the ones that happened to use requireAdmin.

rooms:ADMIN is the only role this app owns. Being signed in is enough to request a booking.

Pagination

GET /api/bookings, /api/rooms, /api/venues and /api/users page in SQL and return an envelope, never a bare array:

{ "items": [ ... ], "total": 412, "limit": 50, "offset": 0 }

limit defaults to 50 and is capped at 200; offset defaults to 0. total is the count the filter matches, not the page length.

GET /api/bookings/stats returns { total, pending, confirmed, upcoming } for the signed-in user, so a caller that only wants counts does not fetch rows to count them.

Every request body and every query string is validated with Zod, from server/utils/validation.ts. A bad filter value is a 400 naming the field, not a cast that silently becomes NaN. An update body carrying no fields at all is also a 400, rather than reaching the ORM and surfacing as a 500.

Bookings

Route Auth Notes
GET /api/bookings session Admins see all; a STANDARD user sees only their own, scoped server-side rather than by a query parameter. Filters: status, startDate, endDate, roomId.
POST /api/bookings session Create a request. See below.
GET /api/bookings/stats session Counts for the caller's own bookings.
GET /api/bookings/:id owner or admin One booking with its relations.
PUT /api/bookings/:id owner or admin Field set depends on role. See below. ?scope=series applies an admin's change to every unfinished occurrence.
DELETE /api/bookings/:id owner or admin Deletes, then notifies the owner. ?scope=series removes the whole recurring series; the default occurrence removes one and promotes the next to head it, in a single batch with the delete (ADR-0003).
PUT /api/bookings/bulk admin { updates: [{ id, data }] }, where data is the admin shape below. Same schema and same occupancy check as the single-row route.
DELETE /api/bookings/bulk admin { bookingIds: number[] }, and ?scope= as on the single route: the default occurrence removes exactly the listed rows, promoting a successor for any that head a series, and series removes every occurrence of each series they belong to (ADR-0007). deleted counts the rows that went, which under series is more than were listed.

Both bulk routes group notifications by user, so someone whose five bookings all move gets one email rather than five.

POST /api/bookings

Field Who Notes
eventTitle anyone Required
startTime, endTime anyone ISO 8601. Required.
numberOfAttendees, notes anyone Optional
recurringPattern anyone { frequency, interval, daysOfWeek, maxOccurrences, endDate }, expands to one booking row per occurrence, each availability-checked before any row is written
userId admin Book on behalf of someone
roomId / externalVenueId admin Assign a space at creation
status admin Override the initial status

Admins are notified of anything left PENDING, as one batched email rather than one per admin. An admin receives it only if they hold the ADMIN_NEW_BOOKINGS preference and take the EMAIL channel. Turning the Email switch off on /settings/notifications stops the fan-out as well as their own booking mail, and push delivers nothing, so that is silence for them; when it leaves nobody to tell at all, the fan-out logs a warning (ADR-0010).

Who counts as an admin for that fan-out comes from stage-door's GET /api/role-holders, cached for ten minutes per isolate, not from the mirror's is_rooms_admin column, which never lapses when a committee-year grant expires. The column is the fallback for when stage-door cannot be reached (ADR-0009).

A recurring request writes its first occurrence, then the pattern and every later occurrence in one batch. The first occurrence has to go first, because the rest hang off its id; if the batch then fails it is deleted again, because a row nobody has been told about would otherwise sit there holding its slot and making the admin's retry clash with a booking they cannot see.

PUT /api/bookings/:id

The two roles are validated against different schemas.

  • Admin: roomId, externalVenueId, status, rejectionReason, allowConflicts, plus the schedule fields eventTitle, numberOfAttendees, startTime, endTime and notes.
  • Owner, PENDING only: eventTitle, numberOfAttendees, startTime, endTime, notes.
  • Owner, PENDING or CONFIRMED: status: "CANCELLED", the only status an owner may set.

Moving a booking is an admin action: changing startTime or endTime re-checks occupancy for the new window, so a move onto an occupied slot is a 409 like any other clash. This is what makes "occurrences are moved one at a time" possible at all.

A body may carry one end of the window on its own, and the resolved window is checked whichever ends it carries: a patch that would leave a booking ending at or before it starts is a 400, not a row that occupies nothing. See data-model.md.

An owner may cancel a confirmed slot but not edit one: giving the room back is theirs to decide, moving it is not. A body carrying status alongside any other field is a 400, so a cancellation cannot smuggle a new window or title past the PENDING-only guard, and the route writes the status on its own whatever else the body held. Every owner cancellation alerts the admins who have opted in, on the same two columns as the fan-out above, and names the external venue when there is one, because that booking was arranged by hand and someone has to unarrange it.

A status change made by an admin notifies the owner, subject to their preferences. It is one email however many rows moved, and under ?scope=series it names the number of occurrences the change covered alongside the first of them, rather than reading as though a single date had changed.

?scope=series covers every occurrence that is not REJECTED or CANCELLED, whatever else it holds: a confirmed occurrence is moved by a series-wide assignment like any other. The admin page previews exactly that set before asking, from the same predicate (shared/utils/bookingStatus.ts).

REJECTED and CANCELLED are terminal. Any change back out of them is refused with 409, whoever asks: a released slot may have been given to someone else, so the booking has to be made again rather than reopened.

Every write goes through applyBookingChange in server/utils/bookingWrites.ts, which re-checks occupancy for the booking as it will be after the patch. A change that would double-book returns 409 with the clashing bookings in data.conflicts. allowConflicts: true is the deliberate admin override and is the only way to write a clash.

Each entry carries id, startTime, endTime and status. eventTitle is the real title only for a caller holding booking.read.any; everyone else gets "Booked", the same masking GET /api/rooms/available applies. An owner editing their own request is told a slot is taken and when, never whose production has it. No entry ever carries the holder's user object.

The UPDATE carries the occupancy rule as well, so a clash that lands between the check and the write matches no rows instead of double-booking the room (ADR-0008). Under ?scope=series, and on the bulk route, the occurrences ahead of a blocked one have already been written when that 409 comes back: D1 treats a statement matching no rows as a success, so the batch commits. Refresh the list rather than assuming nothing landed.

A booking moving to REJECTED or CANCELLED holds nothing, so it is never blocked.

Rooms

Route Auth Notes
GET /api/rooms session Admins get every column plus booking counts and may pass includeInactive=true; everyone else gets the public fields for active rooms only.
POST /api/rooms admin
GET /api/rooms/:id admin
PUT /api/rooms/:id admin Partial body, at least one field. Omitting isActive leaves it alone rather than reactivating the room.
DELETE /api/rooms/:id admin Deactivates by default. ?permanent=true hard-deletes, and is refused once the room has bookings.
GET /api/rooms/available session Required: startTime, endTime, both ISO 8601 and validated. excludeBookingId omits a booking's own rows so editing it does not conflict with itself. Capped, see below.

GET /api/rooms/available

The window is capped twice, and both are 400s (ADR-0011):

  • 31 days is the widest span the schema accepts. The booking forms build both ends from one event date, so no first-party client comes near it.
  • 1000 is the most clashing bookings the sweep will read. Over that the request is refused rather than answered from a truncated set, because the available/unavailable split is derived from those rows: a dropped clash would offer an occupied room to the next member.

The second cap is passed only by this route's sweep. The occupancy gate on the write path shares the same query and reads every clash, uncapped, because a gate that sees part of the picture is worse than no gate.

Who holds a clashing booking is admin-only here and in the 409 the write path returns: without booking.read.any a conflict reads eventTitle: "Booked" and carries no user object.

Venues

Route Auth Notes
GET /api/venues session Admins also get creation dates and booking counts. Filters: campus, building.
POST /api/venues admin
GET /api/venues/:id admin
PUT /api/venues/:id admin Partial body, at least one field.
DELETE /api/venues/:id admin Permanent, and refused while any booking references the venue; unlike rooms there is no deactivated state.

Users

Route Auth Notes
GET /api/users admin The local mirror, not the auth service. search matches name and email.
GET /api/users/:id admin One mirror row with their bookings.
POST /api/users admin Asks the auth service for a shadow account by email (match-or-create, service-token authenticated) and mirrors the canonical id. No passwords are generated; the person claims the account later.

Account

Route Auth Notes
GET /api/account/preferences session Channels and types, parsed from their JSON columns.
PUT /api/account/preferences session Account-security mail ignores both. 409 once the account has been erased: the sealed cookie stays readable afterwards, and an erased row is never written back over.
POST /api/notifications/subscribe session Registers a Web Push subscription, keyed on the unique endpoint.
POST /api/notifications/unsubscribe session By endpoint; the caller may only remove their own.

Nothing sends to push subscriptions. sendPushNotification is a stub. See data-model.md.

Mail is never sent to a placeholder address. A mirror row carrying anonymised_at, or an address under .invalid, is dropped with a warning naming the subject rather than handed to Resend. Three things write such an address: erasure, a merge, and the collision placeholder from ADR-0004. Sending to one bounces, and a hard bounce counts against the domain every other message goes out on.

Inbound GDPR hooks

Called by the auth service, authenticated by hashed service token. All are idempotent, because stage-door retries them until they succeed.

Route Effect
POST /api/_hooks/auth/export { userId } → this app's personal data: the mirror row and their bookings, including notes and rejectionReason
POST /api/_hooks/auth/anonymise Scrubs the mirror row and every free-text field on their bookings. Bookings survive as anonymous rows. Writes the scrubbed row even when nothing was mirrored here, so a sealed cookie cannot mirror the subject back afterwards (ADR-0006). Scrub list below.
POST /api/_hooks/auth/last-activity { userIds } → latest booking activity per user. Chunks its in clause at 90 ids, because D1 caps bound parameters at 100.
POST /api/_hooks/auth/merge { fromUserId, toUserId, dryRun? } → re-points bookings and push subscriptions onto the winner, then scrubs the losing mirror row to the same tombstone erasure leaves. It is not deleted: the loser's cookie stays readable and would insert the row straight back (ADR-0006). The winner's row is minted if it has none, taking the loser's address so booking mail still reaches them until they next sign in. Each statement binds two parameters however many rows move, so no chunking is needed here. The winner's own preferences are untouched. (stage-door ADR-0015)
GET /api/_hooks/auth/manifest This app's declaration: namespace, the roles it reads, and the permissions each carries. The auth service polls it and turns the roles into definitions, so adding a role here is what makes it grantable (stage-door ADR-0017).

What erasure scrubs

The hook is idempotent, because stage-door retries it until it succeeds.

Table Columns
users email becomes deleted-<id>@anonymised.invalid, name becomes "Deleted user", is_rooms_admin cleared, both notification columns emptied, anonymised_at stamped
bookings notes and rejection_reason nulled. Everything else survives, so booking statistics do.
push_subscriptions Rows deleted outright

rejection_reason is admin-written free text about the requester, so it is scrubbed with the notes and is returned by the export hook.

anonymised_at is what stops the scrub being undone. A sealed session stays readable after erasure, and every authenticated request upserts the mirror; the upsert skips a row carrying that column, so the erased details are not written back.

The row is written whether or not one was there, because a column can only hold a write off while there is a row to carry it. The merge hook leaves the same tombstone rather than deleting the losing row, for the same reason (ADR-0006).

The manifest is shared/utils/appManifest.ts, served verbatim. rooms:ADMIN is still the only role this app owns, but the four things it actually gates are now named rather than inferred: admin.access, booking.read.any, booking.manage.any and room.read.inactive. That distinction matters at the sites that redact a field rather than refuse a request, such as the booked-by name in GET /api/rooms/available. Every check now names one of them rather than the role that implies it.

Permissions are lowercase and dotted where roles are uppercase, so no string can be read as both.

Other

Route Auth Notes
GET /api/health public { ok: true }, or 503 with { ok: false, pendingMigrations }. See below.
GET /dev-login dev only Seals a fake session. ?admin=1 grants rooms:ADMIN. Guarded by import.meta.dev, so it does not exist in a production build. The single sanctioned exception to "this app never writes the session".

GET /api/health

Not a plain liveness probe. It compares the migration journal against production's _hub_migrations ledger and returns 503 naming the files that have not been applied, because a Worker deployed ahead of its schema is the failure stage-door ADR-0021 exists for.

An uptime monitor pointed at this will alarm on a missed migration, which is the intent.

The public allowlist in server/middleware/auth.ts is matched against the path with the query string stripped, so a monitor that cache-busts with ?cb=123 still reaches the check rather than being answered 401 by the session guard. A monitor configured to alarm only on 5xx would otherwise read that 401 as healthy and never see the 503.