The host_id invariant

Every host-owned row is scoped by host_id in application code — the full narrative is in docs/02-architecture-review.md.

Every table a host owns (events, invites, rsvps, forms, form_submissions, …) carries a denormalized host_id column, and every host-scoped function in src/lib/data/ takes hostId as an explicit argument and filters on it — never trusting a client-supplied row id alone to prove ownership.

Read and write, scoped the same way

getEventFull (src/lib/data/events.ts:92, filter at src/lib/data/events.ts:97-98) and deleteEvent (src/lib/data/events.ts:346, filter at src/lib/data/events.ts:348) both chain .eq("id", eventId).eq("host_id", hostId)before touching the row — an event id alone (guessable, or leaked from another host’s browser tab) is never enough to read or delete it. The same shape repeats for every mutation on events: updateEventDetails (src/lib/data/events.ts:232), updatePageSchema (src/lib/data/events.ts:382), and the rest of the update* functions in that file.

src/lib/data/invites.ts and src/lib/data/form-submissions.ts follow the identical pattern for their own tables: deleteInvite (src/lib/data/invites.ts:46) filters .eq("id", inviteId).eq("event_id", eventId).eq("host_id", hostId) at src/lib/data/invites.ts:51-53, and deleteSubmission (src/lib/data/form-submissions.ts:140) filters .eq("host_id", hostId).eq("form_id", formId).eq("id", submissionId) at src/lib/data/form-submissions.ts:145-147. In both files the calling server action already resolved hostId from requireHost() — it is never read off the request body.

The deliberate exception: guest-facing reads

A handful of functions are named *Public and are intentionally not scoped by host_idat all, because the caller (a guest’s browser) has no host session to prove one. getInvitePublic (src/lib/data/invites.ts:92) is the clearest example — its own comment at src/lib/data/invites.ts:90-91 says it outright:

“Guest-facing lookup — deliberately unscoped by host_id (the invite id itself is the access control for the public RSVP flow).”

The invite id (a UUID emailed only to that one guest) is the capability token standing in for host_idhere — knowing it is what proves you’re allowed to see that row. getEventByIdPublic (src/lib/data/events.ts:168-178) and getEventBySlugPublic (src/lib/data/events.ts:151-153) are scoped the same way, by the event’s own id/slug (and, for the id lookup, status = 'published' — see the comment above getEventByIdPublicexplaining why a draft event’s id must still 404 even if it leaks). None of these omit scoping outright; they scope by a different secret instead of host_id.

Convention, not Postgres RLS

This is enforced by code convention across src/lib/data/, not by Postgres row-level security policies. supabase/schema-saas.sql:10-12enables RLS on every table but attaches no policies — a deliberate deny-all backstop (the anon/authenticated keys have zero access either way, so a leaked anon key can’t read anyone’s data), not the primary tenancy mechanism. The service-role client used throughout src/lib/data/ bypasses RLS entirely, so the .eq("host_id", hostId) filter in each function body is the actual authorization check.

Good to know

Before adding any new query to src/lib/data/: host reads and writes always filter host_id from an argument the caller resolved via requireHost(), never from client input. A public/guest-facing read is scoped by a different capability token instead (an invite id, an event slug, a verified email) — it is never left unscoped entirely.