Skip to content

Guest Invite Pipeline

Everything between "a host decides to invite someone" and "that guest is working in the portal". Issue #292 collapsed two divergent implementations into one service and fixed the name, link and notification behaviour around it.

Why one service

There were two host-driven invite paths. The in-episode API path gained canonical name resolution (Feb 2026) and an account-holder notification branch (Mar 2026). The create-episode inline path was written in April 2026 as a parallel reimplementation that copied only the guest lookup and the invitation email, so it had neither. A later change to show-notes provisioning (May 2026) then broke its guest sections without touching it.

Divergence ran in both directions, which is why the fix is one implementation rather than two kept in sync.

Source: src/lib/services/guest-invite-service.ts

ExportResponsibility
createEpisodeGuestNormalise the email, resolve identity, reject duplicates, insert, sync the show-notes section
sendGuestInviteCommunicationsChoose the account-holder branch or the external-guest branch and dispatch
inviteGuestToEpisodeWrapper for callers with no reason to interleave work between the two
handleGuestAcceptanceThe side effects that run once, when a guest first accepts
loadInviteContextOne episode + podcast read for a whole batch of invites

The three producers of episode_guests

ProducerPathTreatment
POST /api/guestsin-episode invitecalls the service
/p/[slug]/e/new actioncreate-episode inline invitescalls the service
create_booking_with_attendeespublic booking flowstays in SQL: it is one transaction with the booking and the capacity check, and has no host to attribute the invite to

The service makes no role decisions. Authorization stays at the boundary (Hono middleware for the API, the page guard for the action), so changing who may invite is a middleware change, not a service change.

The name-ownership rule

A linked identity owns its name.

When episode_guests.user_id / podcast_guests.user_id is set, the profile name (display_name, falling back to full_name) wins on every surface, now and over time. Host-typed and booking-form names apply only to unlinked guests, where the latest explicit name wins (which is what lets a corrective re-invite fix the Guests archive).

Names are stored as four snapshots, not read-time joins: podcast_guests.display_name, episode_guests.name, episode_people.name, episode_credits.name. Snapshots stay the storage model (RSS attribution deliberately freezes at publish time, and the guest portal must render without a profile join). What changed is that they are now kept correct at every write, link and rename event.

Migration: supabase/migrations/20260805100200_guest_name_ownership.sql

MechanismWhereWhat it fixes
Write-time resolutionresolveCanonicalGuestName in the serviceThe host-typed name never reaches an account holder's row
Canonical upsertensure_podcast_guest_on_episode_guest_insertThe canonical name was seeded by the FIRST invite and its ON CONFLICT touched only user_id, so a wrong name typed once was permanent. Resolution now happens on the INSERT branch too, not just on conflict
Sync on linklink_orphaned_guests_to_user, link_podcast_guests_to_new_userThese linked the rows but left stale names behind
Propagate on renamepropagate_profile_name_to_guest_snapshots (new trigger on user_profiles)Nothing synced a later rename. Also closes the signup race, since both linking triggers fire AFTER INSERT on auth.users where the profile row may not exist yet
Credit priorityensure_guest_credit_on_activationRanked episode_guests.name above the profile, so host-typed names reached RSS attribution
Booking guardcreate_booking_with_attendeesIts ON CONFLICT clobbered any existing name, linked or not, with whatever was typed into the booking form

Renames reach published episodes

The propagation trigger updates episode_credits on published episodes, so a profile rename changes feed output after the next rebuild. That is the rule working as intended, not drift.

create_booking_with_attendees is re-issued by more than one effort

Any future re-issue must copy the then-current live body and re-apply its own diff on top, never copy from a migration file. The pgTAP case create_booking_with_attendees does not rename a linked guest pins the guard for whoever lands next.

Which communication a guest gets

Decided by whether the invited email belongs to a platform account, never by a caller flag.

AudienceFlow
Invitee with an accountepisode.invited notification: in-app, email and push under the existing policy. The CTA is their guest-portal magic link. No email verification, ever
Invitee without an accountThe invitation email (episode-guest-invitation.ts, invitation-framed per #27) into the verify page, then the OTP code email, then the portal

Asking an account holder to verify an address they already own was the second symptom in #292 and is now structurally impossible: the branch is chosen from a user_profiles lookup inside the service.

Both routes are metered on email_sends_per_month. The notification's email channel meters inside the delivery worker; the invitation email takes an EmailSendMeter built by the service, and a meter-resolution failure skips the send and reports it rather than sending unmetered.

Guest token precedence (#145)

guestAuthHandle resolves a portal credential in this order.

Source: src/lib/server/guest-auth-handle.ts (extracted from hooks.server.ts so it is unit-testable without importing the Hono app).

  1. ?token= in the URL. Deliberately beats the cookie, so a fresh magic link can override a stale cookie left over from a rotated token.
  2. The guest_token cookie. Re-set from whichever token validated.
  3. The caller's own guest row, via refresh_own_guest_access, when a validated app session is present.

Three distinct failures used to collapse into one "Invalid or expired access link" 401:

CauseFix
The SameSite=Strict cookie is withheld on a navigation chain started in a mail clientThe post-verification redirect now carries ?token=, so the target self-authenticates and the cookie is re-set from it
validate_guest_verification_code rotates the access token on every use, killing older links and cookiesThe query token wins over the cookie, and a signed-in user can recover through their own row
A stale app session rode along on locals.supabase and made the SECURITY DEFINER RPC errorThe guest RPCs now run on a sessionless anon client (createGuestRpcClient)

Never pass locals.supabase to a guest RPC

This applies to every guest RPC, not a named few. They all authenticate by the token, code or guest id in their arguments, never by a JWT, and every one is granted to anon. Attaching the visitor's session gives them ambient authority they do not need and makes a valid token look invalid whenever that session has gone stale. authGuardHandle deliberately skips /guest/*, so such a session is never refreshed on this path, which is why the failure was sticky and why it vanished in an incognito window.

The original fix (#292) named only validate_guest_access_token, activate_guest_portal_access and validate_guest_verification_code, and that narrow framing is exactly why nine other call sites kept the defect: get_guest_show_notes, get_guest_episode_messages, get_guest_episode_participants, guest_update_section, get_guest_verification_context and create_guest_verification_code. Closed in #352.

How to call one: requireGuestRpc(locals) ($lib/auth/guest-auth). guestAuthHandle populates locals.guestRpc on every /guest/* request before any page load runs, including the verification routes it otherwise returns early for.

The rule is positional, not textual: inside a (guest) route server, locals.supabase may ONLY be used for .from(...) (an RLS-scoped table read) or .auth.* (account creation). Everything else is a finding, including passing it as a bare argument to a helper that runs the RPC internally, such as getGuestEpisodeContext(locals.supabase, …).

That distinction is load-bearing. A .rpc()-only check passed while (guest)/+layout.server.ts still handed the session client to getGuestEpisodeContext, and that layout runs on every portal page, so the primary path stayed broken behind a green test. Caught in review on PR #356.

The one legitimate exception is refreshOwnGuestAccess, which takes the SESSION client on purpose: it resolves auth.uid() to find the caller's own guest row. It lives in guestAuthHandle, not in a route server.

Enforced by src/routes/(guest)/__tests__/guest-rpc-client.test.ts.

refresh_own_guest_access

SECURITY DEFINER, EXECUTE granted to authenticated only (revoked from PUBLIC and anon by name). Returns the caller's own token for one episode (user_id = auth.uid()), minting a new one only when it is absent or expired so other open tabs keep working.

The access token remains the sole portal credential. The session merely locates a token its owner is already entitled to read, which is the boundary-preserving version of "a logged-in guest just gets in" and keeps the guest portal off Supabase Auth.

guestAuthHandle only redirects when the returned token differs from the one already in the URL, so a token that keeps failing bounces once and then falls through to the 401.

Acceptance

activate_guest_portal_access and validate_guest_verification_code both return did_activate, true only on the invited to active edge. Returning the resulting status was not enough: it reads active both for a guest accepting now and for the same guest reloading a minute later.

handleGuestAcceptance runs on that edge, from both guest-driven entry points, and does three independently-isolated things:

  1. ensureGuestSection so the guest lands on a portal that has their section
  2. episode.guest_accepted to episode_guests.invited_by, falling back to episodes.created_by
  3. emitGuestEvent('guest.responded', ...), a trigger that had zero call sites before #292 and so had never once fired

The edge itself is decided inside activate_guest_portal_access by a single conditional UPDATE ... WHERE status = 'invited', not by a read followed by a write. Two concurrent portal loads would otherwise both observe invited and both claim the acceptance: the notification dedupes on {guestId}:accepted, but the automation event does not, so a host's guest.responded rule would run twice.

invited_by and created_by are history, not authorization

Both record who did something once. podcast_members is who may see the podcast now, and handleGuestAcceptance runs service-role with RLS bypassed, so it re-checks membership before publishing and takes the first candidate who is still a member. Without that, an inviter who has since been removed from the podcast would still be sent the guest's name and email, the episode title, and a link into the podcast. The same reasoning applies to any future notification resolved from a historical column; the house pattern for host-facing recipients is listPodcastMemberUserIds in src/lib/notifications/integrations.ts.

A host flipping the switch is not an acceptance

PUT /api/guests/:id moving a guest to active is an administrative edit. It runs the section backstop and publishes nothing.

Section provisioning

PathProvisioned
In-episode inviteat invite, by the service
Create-flow inline inviteat show-notes creation, via applyShowNotesTemplate({ includeInvitedGuests: true })
Booking flowunchanged: first confirmation via applyShowNotesTemplate, later ones via addGuestShowNotesSections, active-only
Any guest reaching active without oneensureGuestSection, called from all three activation points

includeInvitedGuests is opt-in for a reason

applyShowNotesTemplate filters guests to status = 'active' by default. That is the M8 multi-guest booking invariant: when several bookings share a session, only the just-confirmed guest is active, and the filter is what stops the first confirmation provisioning sections for guests who have not confirmed. /e/new is the one caller that opts out, because there the host staged the guests by hand and no booking exists. Do not change the default.

Internal documentation - Not for public distribution