Skip to main content

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โ€‹

FunctionJob
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โ€‹

  • getPermissionLevel answers 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.
  • requireEventAccess takes a ValidEvent. That signature puts the 404 before authentication and a caller cannot reorder the two.
  • requireEventAccess redirects on NONE and returns Exclude<EventPermissionLevel, 'NONE'>, so callers cannot forget the check. requireEventAdmin asks 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โ€‹

VisitorDebugLevelOutcome
logged outoffโ€”302 /login?return_to=โ€ฆ; never calls getCustomerGid
logged in, memberoffMEMBER302 /party/:eventId
logged in, owner/adminoffOWNER / ADMIN200 editor
logged in or outon, nothing impersonatedOWNER200 editor
logged in or outon, impersonating owner/adminOWNER / ADMIN200 editor
logged in or outon, impersonating memberMEMBER302 /party/:eventId
logged in or outon, impersonating a GID off the rosterOWNERclears 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.