Skip to content

v1.13.4 - #52

Merged
pataniaeli merged 14 commits into
mainfrom
dev
Aug 28, 2026
Merged

v1.13.4#52
pataniaeli merged 14 commits into
mainfrom
dev

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

No description provided.

pataniaeli and others added 14 commits August 27, 2026 22:51
Fixes #44, #45, #46.

- #44: BookingDetails only ever rendered the first one_time_room_bookings /
  weekly_room_bookings row, dropping every additional session on a booking
  (confirmed against prod data -- some One-Time Room events have 2-3 rows).
  Now maps over every session, matching the pattern already used for Tabling
  and in the Administrator bookings tab.
- #46: Event cards now show purpose as the primary (bold) line and the body
  name as secondary, swapped from before. The row-title danger flash moved
  with it.
- #45: Each checklist item now shows a due date, computed from the event's
  earliest session date minus the relevant Danger Range's near edge
  (pa_event_mgmt_danger_end / pa_event_engage_danger_end -- settings that
  already existed for exactly this, per their migration comment: "stored for
  display and future use"). Added a new isActionDanger check to the
  pending-actions cascade so a specific overdue form's own label flashes red
  (.pa-text-danger), independent of the row-level title flash.

isActionDanger is wired into app/(dashboard)/layout.tsx, the provider file as
it stands at this branch's base commit. A concurrent session in this working
tree is mid-refactor of that file (splitting it into a server layout +
dashboard-shell.tsx); that in-progress work is intentionally left uncommitted
here so it isn't disturbed.
Fixes #48.

The /api/events route ordered by created_at (when the booking/tracking row
was made). Events are now sorted by their own date -- the earliest session
date across any of a booking's child sessions (one-time, weekly occurrence,
or tabling), reusing the same sessionDatesOf/minDate helpers already used for
the #45 due-date calc. A booking with no session date sorts last rather than
disappearing.
fix: Events tab multi-session display, ordering, text priority, and form due dates
Load times stayed high even on a warm function because the cost was never on
the server. Every dashboard route was a client component, so nothing could
start until ~250 KB of JS had downloaded and hydrated -- and only then did a
serial, cross-origin chain begin:

  getClaims()    a JWKS fetch to supabase.co: 140-260ms, on a fresh TLS
                 connection, served with no Cache-Control so every page load
                 pays it again
  loadIdentity   users + board_memberships, read from the browser
  /api/my-rooms  a second browser -> Vercel -> Postgres hop, with an Upstash
                 rate-limit round trip awaited in front of all data work

AuthGuard held the content area behind a skeleton for the first two, so all of
it sat in front of first paint. None of it is cached on a machine the user has
not opened Chambers on before, which is exactly where it was worst.

The database was never implicated: 40 bookings, 371 occurrences, 27 users.

- (dashboard)/layout.tsx becomes a server component that resolves identity in
  two in-region reads and passes it down. AuthGuard is deleted; AdminGuard and
  EventsGuard read identity from context and render synchronously. As a
  side effect, unauthenticated requests now 307 before any JS is sent, rather
  than receiving the whole bundle and redirecting from the client.

- /my-rooms reads its bookings in-process while rendering and hands them to
  the client as props. The query layer moves to lib/my-rooms-data.ts so the
  API route -- still used to refresh after a mutation -- shares one
  implementation.

- The rate limiter in /api/my-rooms is started rather than awaited. Its
  verdict is still checked before anything is returned, so a throttled caller
  is refused either way.

- The service worker precached every build artifact, 103 entries, on first
  visit -- downloading the entire app while the requested page was still
  fetching its own JS. Route chunks, the noModule polyfills and the
  email-only logo now load at runtime instead: 29 entries.

flattenMyRooms deliberately runs in a mount effect rather than during SSR. It
drops bookings before "today", and "today" is the viewer's local date, so
computing it against the server's UTC clock would hand an EDT user loading
after 8pm a list built for tomorrow and hydrate onto rows that do not match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict was in (dashboard)/layout.tsx, and it was structural rather than
textual: this branch turns that file into a server component and moves the
client shell to dashboard-shell.tsx, while #47 edited the client shell in
place. Git could not see the rename, so it diffed a server component against
a client one.

Resolved by keeping this branch's layout.tsx (the server component) and
reapplying #47's issue-#45 work to dashboard-shell.tsx, which is where the
client shell now lives:

  - the dangerActionIds memo
  - isActionDanger on the PendingActionsWatch value
  - dangerActionIds added to that memo's dependency array

This is not optional after the merge: dev's PendingActionsWatch interface now
declares isActionDanger, so the shell has to provide it to typecheck.

Verified by diffing dev's layout.tsx against the merged dashboard-shell.tsx --
the only remaining differences are this branch's own 65 lines.

Also fixes a genuine bug in the branch's first commit. The buildExcludes
change altered which workbox modules next-pwa bundles, so the generated
sw.js began importing ./workbox-8b396bde while the repo still carried only
workbox-4754cb34.js. sw.js resolves that import by filename at registration
time, so the service worker would have failed to load in production. The
runtime file is now committed alongside the sw.js that references it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems found in testing.

Role changes did not take effect until sign-out. isAdmin/isIEMS were read from
the JWT's app_metadata, which is a copy stamped into the access token when it
was issued. Granting or revoking a role writes the users row and the auth
metadata; neither touches a token already sitting in someone's browser, so a
revoked admin kept passing the check until the token expired (an hour by
default). resolveShellIdentity now reads admin_role/iems_role from the users
row it was already selecting, so the cost is nothing and the answer is current
on the next navigation.

`is_admin` in the token is exactly `admin_role != null` -- see the sync in
app/api/administrator/users/route.ts -- so this is the same predicate read from
the authoritative side, not a new rule.

This fixes what the dashboard renders and which pages the guards allow. It does
NOT close the hole: every route under /api/administrator authorizes on
`user.app_metadata?.is_admin`, and the SQL is_admin() behind RLS reads the same
token claim, so a revoked admin can still call those directly until it expires.
That spans ~25 routes plus a database function and wants its own change.

The Events tab showed an IEMS user almost nothing. The listing read `bookings`
through RLS, and bookings_select_admin_or_member grants a row to admins and to
members of the owning body -- IEMS is neither, it is an app-level role the
policy has no concept of. An IEMS user got only the events owned by bodies they
happen to sit on: 1 of 4 on current data, which reads as "there are no events".
The listing now runs as service role, gated by the route's own admin-or-IEMS
check. /api/events/checklist already worked this way; the listing was the
outlier. This also restores the "Requested by" name, which RLS
(users_select_admin_or_own) had been blanking for IEMS.

The third report -- "isActionDanger is not a function" on Events as an admin --
was the pre-merge state of this branch: dev's events page calls isActionDanger
while the shell did not yet provide it. Resolved in 5ffc6fc; no code change
needed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the skeleton frame on My Rooms. The rows are now flattened during the
server render and arrive as props, so the first paint is the list itself.

The reason that was not done in the first place: flattenMyRooms drops bookings
before "today", and "today" was `new Date()` + setHours(0,0,0,0) -- local
midnight in whatever timezone the *runtime* happens to be. That is UTC inside a
Vercel function and the viewer's zone in the browser, and for the last hours of
every Eastern day they name different dates. At 9pm EDT on Aug 27 the server
computes Aug 28 and drops the day's bookings; the browser computes Aug 27 and
keeps them. React would find markup that does not match what it renders while
hydrating, throw the server HTML away and redraw -- a worse flash than the
skeleton, with the day's bookings briefly missing.

So rather than work around the ambiguity, this removes it. booking_date,
occurrence_date and session_date are DATE columns: no time, no offset. They mean
a calendar day in Boston, because that is where the rooms are. todayInAppZone()
formats the current instant in America/New_York and returns 'YYYY-MM-DD', which
is the same string on both sides. The server resolves it once and passes it down
with the rows, so the client's first render uses the identical value and cannot
disagree even across a midnight boundary or clock skew.

Every date decision on the render path now takes that string:

  - flattenMyRooms(data, today)   past bookings, by ISO string compare
  - isWithinDays(date, n, today)  the "Next N Days" window
  - CalendarView today={today}    highlighted cell, and the month it opens on

isWithinDays now measures whole days between two date strings anchored at
Date.UTC rather than subtracting Dates built from local midnight -- across a DST
boundary two local midnights are 23 or 25 hours apart, which divides to 0.958 or
1.04 days instead of 1.

This is also a correctness fix independent of hydration. "Today" was previously
the viewer's date, so a student on co-op in California at 10pm PT saw a
different day's schedule than their peers on campus, and the calendar's "Today"
button jumped to a different cell than the one it highlighted. Both now follow
Boston.

The skeleton is kept for refetches after a mutation, which is what it was for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revoking someone's admin role did not take effect until their session ended.

Both halves of the authorization stack answered from the JWT. The app routes
read `user.app_metadata?.is_admin`, and the SQL is_admin() behind every RLS
policy read `auth.jwt() -> 'app_metadata' ->> 'is_admin'`. app_metadata is a
copy stamped into the access token when it was issued; granting or revoking a
role writes the users row and the auth metadata, and neither touches a token
already sitting in a browser. So a revoked admin kept full admin rights until
that token expired -- an hour by default, and longer while the tab kept
refreshing it.

The signature checking was never the problem. The claim was fresh at issue time
and simply went stale, and nothing re-read it.

my_body_ids() and my_divisions() sit right next to is_admin() and resolve
board_memberships through auth.uid() on every call, which is why membership
changes always took effect immediately. is_admin() was the outlier; this brings
it in line.

Database
  is_admin() now selects from users (admin_role is not null and is_active), and
  a matching is_iems() replaces the iems_role expression that was only ever
  written inline. SECURITY DEFINER does double duty: it lets the function read
  users when the caller cannot, and it keeps users_select_admin_or_own -- which
  calls is_admin() -- from recursing, because the definer's query is not subject
  to RLS. That is the same mechanism my_body_ids() already relies on.

  The semesters and event_tracking policies inlined the raw JWT expression
  rather than calling is_admin(), so redefining the function alone would have
  left them stale. They are rewritten onto the helpers.

  is_active is folded into both helpers on purpose: deactivating someone should
  revoke their rights, not just hide the UI.

Application
  getAuthedUserWithLiveRoles() returns the same AuthedUser shape with is_admin,
  admin_role and iems_role overwritten from the users row, so each of the 30
  routes that make a role decision changes by one import and keeps its
  authorization branch exactly as written. That is deliberate: a mechanical swap
  is a far smaller surface for a mistake than rewriting thirty branches, and
  this is precisely the code where a mistake is expensive.

  The service-role routes are the reason this cannot wait for the migration
  alone. They bypass RLS entirely, so the app-level check is the only thing
  between a stale token and a privileged write -- the database never sees those
  calls.

  Routes that only need the caller's id keep plain getAuthedUser() and the cheap
  path: /api/my-rooms, /api/me/*, /api/alerts, /api/cancellation-requests,
  /api/cron/warm.

Verified equivalent before changing anything: across every current user,
(admin_role is not null and is_active) matches the token's is_admin claim
exactly, so this is the same answer read from the authoritative side. What
changes is when it updates -- now on the next request rather than the next
sign-in.

The migration is NOT applied. Review and run it before merging the app changes,
since the two halves are independent and the app half is safe on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit made each authorization check read the users table, so a
revoked admin stops being an admin on their next request. This ends the session
outright instead of leaving them signed in as a normal user.

Deleting the session rows is not sufficient on its own, and the reason is the
whole design here. Access tokens are ES256 and verified locally against a cached
JWKS -- deliberately, since that is what keeps auth off the network on every
request. A token whose session has been deleted therefore still passes signature
verification until it expires. Removing auth.sessions kills the *refresh*, which
puts the user out within the access token's lifetime -- an hour by default -- not
at once.

So revocation also stamps users.sessions_revoked_at, and the app refuses any
token issued before that stamp. The JWT's `iat` is already in the verified
payload, and getAuthedUserWithLiveRoles() already reads that row for the caller's
roles, so the check adds no query and no latency. The result is immediate: the
very next request from a revoked session is refused.

`iat` is whole seconds, so the stamp is floored and the comparison is strict. A
token minted in the same second as the revocation is allowed, which avoids
rejecting the fresh token of someone signing straight back in; the window that
leaves is one second wide, against an attacker who would have had to
re-authenticate inside it and would then hold a legitimate session anyway.

revoke_user_sessions() is SECURITY DEFINER because the auth schema is not
reachable by the API roles, and execute is granted to service_role only --
explicitly revoked from anon and authenticated, since a function that can delete
anyone's sessions is not something a logged-in user should be able to call.

Sessions are ended on a grant as well as a revocation. A promoted user does not
strictly need it now that roles are read live, but it means no one is ever
carrying a token that disagrees with their row, and the cost is one re-login
after a change that happens rarely.

The revoke call is placed after the role write and its failure is surfaced rather
than swallowed: an admin who believes they have cut someone off needs to be told
when they have not.

DEPLOYMENT ORDER: apply the migrations before deploying this. The users select
now names sessions_revoked_at, and against a database without that column the
query fails, profile comes back null, and every caller is denied -- fail-closed,
which is the right direction for a security check but would take the admin
surface down until the migration lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found while verifying the migration against production before
applying it. Both came from reading pg_policies.qual alone.

The semesters policies are granted TO authenticated. A create policy with no
`to` clause defaults to PUBLIC, so recreating them without it would have
silently widened the grant to include anon.

event_tracking_insert was missed entirely. An INSERT policy carries its rule in
WITH CHECK, not qual, so it read as having no rule and never got rewritten --
it would have been the one policy left on the stale JWT claim. The semesters
UPDATE policy likewise had a WITH CHECK of its own that was being dropped.

Both files now recreate each policy with its original role grant and its
original USING / WITH CHECK split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A leadership member whose admin role had been revoked, and who had not signed
out, could still request bookings for bodies they hold no Leadership in. The
route was already converted, but the escalation happens one level down:
validateScopeSelection skips the "you hold Leadership in that body" test
entirely when ctx.isAdmin is true, and loadScopeContext derived that from
app_metadata on whatever user it was handed. The same claim decides which bodies
the request form offers, so the UI presented every body and the write accepted
them.

Auditing that turned up two more routes with the same hole, missed by the
earlier sweep because the claim is not read in the route itself:
/api/cancellation-requests and /api/revision-requests both reach
requireBookingManager(), which short-circuits on user.app_metadata?.is_admin,
and both were still passing a token-derived user. A revoked admin could cancel
or revise anyone's booking. Both now resolve live roles.

Converting call sites one at a time does not stop this recurring, though, since
the helpers cannot see how their caller authenticated. So the trust is now
explicit: getAuthedUserWithLiveRoles marks the user rolesVerifiedLive, and the
shared helpers grant admin only through hasLiveAdmin(), which requires that
mark. A caller that has not resolved live roles gets isAdmin false.

That direction is deliberate. Forgetting to resolve live roles now
under-privileges rather than over-privileges, which turns this class of mistake
into a visible bug report instead of a silent escalation.

The one caller that still passes a token-derived user is /api/my-rooms, which
uses the context only for bodyIds and divisions and already forces isAdmin
false, so it is unaffected and keeps its cheaper auth path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ions

perf: resolve the dashboard session on the server, not in the browser
security: resolve roles from the users table, not the access token
package.json, the lockfile's two root entries, and the sidebar label.

Only lines 3 and 9 of package-lock.json are the project's own version -- the
other 1.13.x strings in that file are dependency versions and are left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pataniaeli pataniaeli self-assigned this Aug 28, 2026
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chambers Ready Ready Preview Aug 28, 2026 4:43am

@pataniaeli pataniaeli added bug Something isn't working enhancement New feature or request labels Aug 28, 2026
@pataniaeli
pataniaeli merged commit 980e8fb into main Aug 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant