Party Planner Event Access
/party/:eventId (dashboard layout) and /party/:eventId/palette (editor, deliberately outside that
layout) gate the same event. Both call the same two helpers in app/lib/party/access.server.ts, in
the same order.
What each function doesโ
| Function | Job |
|---|---|
requireValidEvent(eventId, enrich, ctx) | Loads the event or 404s. Always uncached โ see The event read. |
requireEventAccess(event, ctx, { returnTo }) | Resolves who is viewing and what they may do, or redirects. Returns { shopifyGid, permissionLevel, isDebugModeEnabled }. |
resolveDebugGid(session, event) | Debug mode only โ views the event as the impersonated member, else the owner. Clears an impersonation whose GID left the roster. |
requireCustomerGid(customerAccount, session, returnTo) | Checks isLoggedIn, then reads getCustomerGid. Redirects to login if either fails. |
getPermissionLevel(gid, event) | Returns OWNER / ADMIN / MEMBER / NONE. The only membership check. |
isEventAdmin(level) | True for OWNER and ADMIN. |
api.party.debug.ts owns setImpersonation. Loaders read it or clear it but never select, so a
prefetch="intent" hover cannot change who you are viewing as.
The gateโ
getPermissionLevelanswers membership, so no route runs a separate roster check. It runs ahead of the Admin API enrichment fan-out, so a non-member 404s without triggering it.requireEventAccesstakes aValidEvent. That signature puts the 404 before authentication and a caller cannot reorder the two.requireEventAccessredirects onNONEand returnsExclude<EventPermissionLevel, 'NONE'>, so callers cannot forget the check.requireEventAdminasks only the admin question.- Debug mode derives the level from the impersonated GID, so an impersonated member hits exactly the denials a real member hits. It grants access without authenticating at all โ a deliberate bypass for testing.
The event read is always uncachedโ
Every route gates in its own loader โ a nested child does not inherit the layout's gate, and a single-fetch request can target one child alone. So one page view runs the gate two or three times, and the read behind it decides what each of those gates sees.
requireValidEvent therefore takes no cache strategy: the read is always CacheStrategy.none.
WithCache has no invalidate, so a cached read serves a pre-write snapshot for its whole SWR
window, and a stale roster reads as "not a member".
That makes caching here an authorization bug rather than a staleness one. A member who joins inside
the SWR window resolves to NONE and gets a 404 on a page they may legitimately see. Because a
stale hit also revalidates in the background, the next attempt succeeds โ so the failure reads as
intermittent and points away from the cache.
The gate's reads are not deduped, so a page view costs two or three backend reads. Those loaders
run in parallel, so that is load rather than latency. A request-scoped memo keyed on context
collapses them to one if that load ever matters.
enrich stays a per-caller argument: the layout passes true because it renders member names, and
the children pass false because they only need the roster. Getting it wrong costs placeholder
names, not access.
getEvent's cacheStrategy is required โ there is no default to fall back into. EVENT_QUERY
returns the whole event aggregate (name, date, palette, roster, products), and every one of those
fields is written by some event mutation, so no caller can safely omit it.
Outcomes โ palette editorโ
| Visitor | Debug | Level | Outcome |
|---|---|---|---|
| logged out | off | โ | 302 /login?return_to=โฆ; never calls getCustomerGid |
| logged in, member | off | MEMBER | 302 /party/:eventId |
| logged in, owner/admin | off | OWNER / ADMIN | 200 editor |
| logged in or out | on, nothing impersonated | OWNER | 200 editor |
| logged in or out | on, impersonating owner/admin | OWNER / ADMIN | 200 editor |
| logged in or out | on, impersonating member | MEMBER | 302 /party/:eventId |
| logged in or out | on, impersonating a GID off the roster | OWNER | clears the impersonation, 200 editor |
Debug mode ignores login state โ it takes the resolveDebugGid branch, so requireCustomerGid never
runs and the visitor's own identity has no effect on the outcome.