/** * Site-visitor session probe client. * * Customer sites poll "who is signed in?" on load to render the right auth UI. * Signed-out is the EXPECTED state for the vast majority of visits, so this * client treats it as normal and never logs: * * - New servers answer an anonymous probe with `200 { "visitor": null }`. * - Old servers (pre-fix) answer an anonymous probe with `401`. * * Sites bump `@duffcloudservices/cms` independently of server deploys, so this * client must accept BOTH shapes and stay silent on the anonymous path — a * `console.error`/`console.warn` on every signed-out page load is exactly the * console-noise this fixes. A genuine signed-in visitor is returned as a * normalized `SiteVisitor`; anything else resolves to `null`. * * ONE EXCEPTION, added by C-298 layer 2. "Signed out" and "this host does not route * `/api/v1/*` to the platform API at all" used to be the SAME observation here: the * default base is the RELATIVE `/api/v1`, so on a host whose Front Door config lacks the * route, Front Door's catch-all answers with the SPA shell at HTTP 200, `response.json()` * throws, and this client silently resolves to signed-out. That is precisely how KEPT — a * paying customer — ran with all nine login routes dead for ~2 weeks with nothing * detecting it (C-261), and it is still live today on `www.nateduff.com`. * * A non-JSON body is therefore NOT treated as an anonymous visit: it is reported loudly * (once per probe, naming the URL and the received content-type) and the UI still degrades * to signed-out, because a login button that cannot work is better than a broken page. * The anonymous path — `200 {"visitor":null}`, a legacy `401`, a network error — stays * exactly as silent as before. */ import { platformFetch, readPlatformJson, isPlatformFetchError, type PlatformBodyClass, } from '@duffcloudservices/cms-core' import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue' /** A signed-in site visitor. */ export interface SiteVisitor { id?: string email: string name: string picture?: string createdAt?: string } export interface SiteVisitorSessionResult { /** The signed-in visitor, or `null` when signed out. */ visitor: SiteVisitor | null /** Convenience flag — `true` iff a visitor is present. */ authenticated: boolean /** * `true` when the probe did not reach the platform API at all — the response carried a * non-JSON body (C-298 layer 2), i.e. this host does not route `/api/v1/*` to the API. * Distinct from an ordinary signed-out visit: a site can render "sign-in temporarily * unavailable" instead of a login button that cannot possibly work. Absent/`false` on * every normal path, so existing consumers are unaffected. */ apiUnreachable?: boolean } export interface FetchSiteVisitorSessionOptions { /** API base (default `/api/v1`, same-origin via Front Door). */ apiBaseUrl?: string /** Optional AbortSignal to cancel an in-flight probe. */ signal?: AbortSignal } const SIGNED_OUT: SiteVisitorSessionResult = { visitor: null, authenticated: false } const apiBase = (value?: string): string => (value ?? '/api/v1').replace(/\/$/u, '') /** * Normalize a parsed `/site-auth/session` BODY into a `SiteVisitor | null`. * * This is the payload-level answer to "is anyone signed in?" — the question the HTTP * status cannot answer, because an anonymous visitor gets `200 { "visitor": null }` * (A-35). Use it when your own code already parsed the body; use * {@link readSiteVisitorSessionResponse} when you are holding a `Response`. * * `null` means signed out. Deliberately fail-closed on every ambiguity: * - a MISSING `visitor` key yields `null`, never a truthy `undefined` — the trap that * made kept's `visitor.value = data.visitor` a latent defect (C-619); * - an envelope claiming `authenticated: true` with no visitor object yields `null`; * - a visitor object without an email is not a visitor. * * Accepts the primary/legacy `{ visitor: {...} | null }` envelope and the contracts-spec * `{ authenticated, user: {...} }` shape defensively. */ export function siteVisitorFromSessionPayload(data: unknown): SiteVisitor | null { if (!data || typeof data !== 'object') { return null } const obj = data as Record const raw = obj.visitor ?? obj.user if (!raw || typeof raw !== 'object') { return null } const v = raw as Record const email = typeof v.email === 'string' ? v.email : '' if (!email) { return null } return { id: typeof v.id === 'string' ? v.id : undefined, email, name: typeof v.name === 'string' ? v.name : email, picture: typeof v.picture === 'string' ? v.picture : undefined, createdAt: typeof v.createdAt === 'string' ? v.createdAt : undefined, } } /** * Body classes that mean the request never reached the platform API, as opposed to an * API that answered. `empty` and `json` are NOT here: an empty 200 or a truncated body * is an ordinary failed read, and reporting it as a misroute would cry wolf. */ const UNREACHABLE_BODY_CLASSES: ReadonlySet = new Set([ 'spa-html', 'html', 'other', ]) /** * Decide "is a visitor signed in?" from a `/site-auth/session` `Response` you already * fetched yourself. * * THIS IS THE SEAM FOR HAND-ROLLED SESSION STORES. {@link useSiteVisitorSession} owns its * own fetch, which a site with an existing store (Pinia, caching, in-flight dedup, its own * authenticated `apiFetch`) cannot adopt — so it hand-rolls the gate and re-derives the * A-35 defect: `if (response.ok) return true`, which reports EVERY anonymous visitor as * authenticated because anonymous is `200 { "visitor": null }`. Measured cost when that * happened on kept: one member-gated 401 per page view, 346 in a day (C-619). * * Keep your fetch; hand the `Response` here and read the payload-derived answer: * * ```ts * const response = await apiFetch('/api/v1/site-auth/session') * const { visitor: v } = await readSiteVisitorSessionResponse(response) * visitor.value = v * return v !== null // never `return response.ok` * ``` * * Fails CLOSED everywhere: a non-2xx (incl. a legacy `401`), a missing response (your * fetch threw), an unparseable body, or a `null` visitor all resolve to signed-out. The * status can never on its own produce `authenticated: true` — only a real visitor object * in the body can. * * Silent on every anonymous path. LOUD exactly once when the body was not JSON at all * (`apiUnreachable: true`), because "this host does not route `/api/v1/*` to the API" must * not masquerade as "signed out" — that ambiguity is how kept ran with nine dead login * routes for ~2 weeks (C-261 / C-298 layer 2). * * Never throws. */ export async function readSiteVisitorSessionResponse( response: Response | null | undefined, ): Promise { // The caller's fetch rejected (network / abort / CORS) and passed us nothing. if (!response) { return SIGNED_OUT } // Old servers reply 401 to an anonymous probe; new servers reply 200-null. Treat any // non-OK status as signed-out WITHOUT logging — and without reading the body, so a // visitor object smuggled alongside a rejected status can never authenticate. if (!response.ok) { return SIGNED_OUT } let data: unknown try { // `silent: true`: we decide what deserves the console below, so an ordinary failed // read stays as quiet as it has always been. data = await readPlatformJson(response, { silent: true }) } catch (e) { if (isPlatformFetchError(e) && UNREACHABLE_BODY_CLASSES.has(e.bodyClass)) { console.error(e.message) return { visitor: null, authenticated: false, apiUnreachable: true } } return SIGNED_OUT } const visitor = siteVisitorFromSessionPayload(data) return { visitor, authenticated: visitor !== null } } /** * Fetch the current site-visitor session. Never throws and never logs for the * anonymous case: any network error, non-OK status (incl. a legacy `401`), or * `{ visitor: null }` body resolves to a signed-out result. */ export async function fetchSiteVisitorSession( options: FetchSiteVisitorSessionOptions = {}, ): Promise { const base = apiBase(options.apiBaseUrl) let response: Response try { response = await platformFetch(`${base}/site-auth/session`, { method: 'GET', credentials: 'include', headers: { Accept: 'application/json' }, signal: options.signal, }) } catch (e) { // A NON-JSON body is a misroute, not an anonymous visit: the request never reached // the API. platformFetch has already written the diagnosable message to the console; // flag it so the UI can say so, and still degrade to signed-out so the page renders. if (isPlatformFetchError(e)) { return { visitor: null, authenticated: false, apiUnreachable: true } } // Network error / aborted / CORS — signed-out is the safe probe assumption. return SIGNED_OUT } // ONE implementation of the gate: the composable, this function, and any site that // adopts the seam directly all decide "authenticated" the same way, so they cannot drift. return readSiteVisitorSessionResponse(response) } export interface UseSiteVisitorSessionOptions extends FetchSiteVisitorSessionOptions { /** Probe automatically on mount (browser only). Default `true`. */ fetchOnMount?: boolean } export interface UseSiteVisitorSessionReturn { /** The signed-in visitor, or `null` when signed out / not yet loaded. */ visitor: Ref /** `true` iff a visitor is present. */ isAuthenticated: ComputedRef /** `true` while a probe is in flight. */ isLoading: Ref /** * `true` when the last probe did not reach the platform API (non-JSON body — C-298 * layer 2). Render "sign-in unavailable" rather than a login button that cannot work. */ apiUnreachable: Ref /** Re-run the probe. */ refresh: () => Promise } /** * Vue composable wrapper around {@link fetchSiteVisitorSession}. Reactive * `visitor` / `isAuthenticated` / `isLoading`, and probes on mount by default. */ export function useSiteVisitorSession( options: UseSiteVisitorSessionOptions = {}, ): UseSiteVisitorSessionReturn { const { fetchOnMount = true, ...fetchOptions } = options const visitor = ref(null) const isLoading = ref(false) const apiUnreachable = ref(false) const isAuthenticated = computed(() => visitor.value !== null) async function refresh(): Promise { isLoading.value = true try { const result = await fetchSiteVisitorSession(fetchOptions) visitor.value = result.visitor apiUnreachable.value = result.apiUnreachable === true } finally { isLoading.value = false } } if (fetchOnMount && typeof window !== 'undefined') { onMounted(() => { void refresh() }) } return { visitor, isAuthenticated, isLoading, apiUnreachable, refresh } }