ADR-020: One auth-optional, auth-aware checkout surface
Status
Accepted
Context
#822 added a public, no-login lockout checkout so staff can close walk-ins on the customer's own device. The pre-existing reservation checkout (/checkout/reservation?s=:id) was auth-required — it redirects to /auth with no session. Rather than make that page auth-optional, #822 added a second page (/checkout/lockout-invitation/:id) that is auth-optional, and a server-side decision (invitationUserIsSetUp) that picks which URL to hand out per recipient.
So one invitation id now has two landing pages with opposite auth postures, and a guess — made at link-generation time — decides which a recipient gets. Both pages share the same UI tree (CheckoutExperience → useCheckoutFlow → CheckoutLayout), which was written assuming an authenticated user.
That split has produced, in two weeks:
- MG-WEB-7A — the shared component's non-invitation branch calls
getAuthHeaders()(throws for anon) infetchSession/fetchInitialPreview; the public page is anonymous, so it threw on every load (fixed #825). FourgetAuthHeaderssites have needed anon-graceful patches over the feature's life. - The send-email regression — the staff "Send to Customer" button regex-parsed
?s=out of the URL; the new path URL has none, so email send bailed for every new-customer invite (fixed #826). - A fail-unsafe router —
invitationUserIsSetUpinferred "set up" from the absence of stub markers, so a partial user object emitted the auth-required URL → a stub gets a login wall. Stopgapped to fail-safe in #827.
The common root: auth was modeled as a fork in the routing (authed page vs anon page, chosen by a server-side guess) rather than as auth being read on the server and resolved into capabilities. The guess can be wrong (a set-up member who opens the link logged out should still be able to pay), the two URLs aren't symmetric (parser/field-loading dependencies), and the shared component must pretend it is always one or the other.
We already have the correct pattern in-tree. The waitlist invite (#792, "auth-aware saved methods") runs the same CheckoutExperience in invitation mode: a server endpoint returns { clientSecret, customerSessionClientSecret, summary }, and the client does zero authed fetches. The server resolves whose Stripe customer session to attach (the invitation's customer) and hands the client secret over. Because auth is read and applied server-side, waitlist has never had an anon bug. The lockout flow instead reused subscription mode, whose client-side /checkout/customer-session fetch predates lockout (965721a71) and was written for an authenticated reservation — calling getAuthHeaders() (which throws for anon) on a page that is now sometimes anonymous. The bug class is the gap between these two patterns.
This is also what Supabase's own SSR guidance prescribes: "Never trust supabase.auth.getSession() inside server code … always use supabase.auth.getClaims() to protect pages and user data." Auth is validated on the server (cookies → getClaims/getUser), refreshed by middleware — not detected ad hoc on the client. Our verifySession() (lib/dal.ts) and getServerAuthHeaders() (lib/supabase-server.ts) already implement exactly this; the unified checkout should lean on them rather than on the client getAuthHeaders() throwing.
Decision
Collapse to one checkout surface that is auth-optional and auth-aware, generalizing the proven #792 waitlist pattern: auth is read and resolved on the server; the server provides capabilities; the client never gates rendering or payment on a session.
- One route (
/checkout/lockout-invitation/:id). Drop the?s=variant for monthly,invitationUserIsSetUp,InvitationUrlOptions, and the user-field selects that fed them. The id is the capability; the page renders and takes payment for anyone holding the link. - Server resolves auth and provides the customer session — gated on invitation ownership. The page (a server component) reads the viewer's auth with
verifySession()(getClaims, per Supabase SSR). The lockout invitation endpoint mintscustomerSessionClientSecretonly when the authenticated viewer owns the invitation (auth.userId === invitation.userId) — exactly the gate waitlist (#792) uses (callerMatchesInvitation,waitlist-invitation/[id]/route.ts:43,90,129). So: owner-logged-in → their saved methods; anonymous stub → none; non-owner holding the link → none. This gate is load-bearing for privacy: minting "for the invitation's customer" unconditionally would disclose the owner's saved cards to anyone holding the unguessable link. Identity is matched server-side, never on the client. - Lockout opts out of the client-side
/checkout/customer-sessionfetch; it does not delete it.fetchSession/ensureUserExistsare verified to also serve authed hourly, reservation-monthly, credits, and org-dedicated checkout (saved-PM prefill;ensureUserExistsis the only guard against a 404 oninitialize-*for brand-new OAuth users). So lockout consumes the server-providedcustomerSessionClientSecret(via the page/GET) and skips the client fetch through a per-config flag; the other flows are untouched. - Client auth headers only for genuinely client-initiated authed calls, through one helper that attaches the session if present and is anonymous otherwise (built on the existing
getAuthHeaders, made graceful). Lint-ban rawgetAuthHeaders()infeatures/checkout/. - Account header via
AuthContext(client JWT decode) — already landed; shows the avatar iff authed, nothing for anon. - Preserve lockout's genuine differences from waitlist: lockout mints its subscription at confirm (deferred), where it is created and paid in the same step — not pre-issued at invite like the waitlist PaymentIntent. (A subscription pre-issued and left unpaid would hit Stripe's ~23h
incomplete_expiredtimer; minting at confirm sidesteps that entirely, and the same timer harmlessly auto-cleans abandoned attempts.) Lockout also has a dynamic card/ACH preview (proration differs by method). These stay; only the auth/customer-session mechanism converges. There is no pay-window or "cliff" on the live flow.
Alternatives rejected. (a) Keep the dual scheme and harden it (the #825/#827 stopgaps) — removes individual bugs but not the class; the implicit anon-safety contract stays unenforced. (b) Client-side session detection + a client /checkout/customer-session fetch (this ADR's own first draft) — re-derives a weaker version of #792 and contradicts Supabase SSR ("never trust the session client-side for authz"); the server already has validated auth, so resolve it there. (c) Two fully separate components — duplicates the checkout UI, which must stay pixel-identical. (d) Server-route by account state but unify the component — still a guess, still the field-loading and parser fragility.
This reverses #822's two-page split (made to reuse the authed page quickly) now that its cost is known, and brings lockout onto the same footing as waitlist.
Consequences
Benefits
- Authed members keep 100% of the authed experience — the server resolves their customer session and provides it; nothing is lost by supporting anon.
- The bug class is eliminated by construction: one URL (no parser/field-loading/fail-unsafe foot-guns), no client-side authed fetch on an auth-optional page (no
getAuthHeadersthrow, no scattered catches), identity matched server-side (no client match check). - Lockout converges onto the waitlist pattern, so the two anon checkouts share one mechanism instead of diverging.
Tradeoffs
- The invitation endpoint mints
customerSessionClientSecretserver-side, gated on invitation ownership (one extra Stripe call on the read path; waitlist already does this). - Keeps two payment modes in
CheckoutExperience(waitlist's pre-issuedpaymentvs lockout's confirm-time deferredsubscription) — the convergence is on the auth/customer-session axis, not a full mode merge. - A migration that partially unwinds #822 and touches the shared checkout component used by every checkout type — not just lockout.
Risks
- The money path (subscription
initialize/ ACHfulfill) is sensitive; the refactor must preserve its idempotency guards (usedAt,billingStartDate) and be verified with real Stripe flows (card + ACH), not just unit tests. - Feature parity is the real cost (firsthand audit, 2026-06-15). Pricing is provably equal across the public and authed paths (both call
createLockoutSubscription/previewMonthlyLockoutwith identical inputs), but routing set-up members onto the unified surface would lose member capabilities the audit confirmed are only on the authed path: promotion/referral/URL-discount codes, express checkout, conversion tracking + Meta CAPI attribution metadata, and theapprovedpurchase gate. "One URL for everyone" therefore requires porting those onto the unified surface — it is a feature-parity project, not a refactor. See the implementation plan for the gap list and the scope options. - The shared component serves hourly/migration/reservation/credits/org-dedicated flows too (all run the non-invitation branch); the change must leave them untouched and be verified across the full route matrix before it ships.