From a40afb9e7af753dddd16144ffe13c8a340be4e63 Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 17 Sep 2026 19:22:00 +0530 Subject: [PATCH] fix(stores): hold the estate fetch until there is a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/sites 401 (Unauthorized)` fired on the sign-in page for every visitor who had not signed in yet. WorkspaceProvider sits above the whole route tree, /login included, and it already carried a comment saying the estate was "gated on the session ... asking for the estate before anyone has signed in would put a guaranteed 401 in the console on every visit to the sign-in page". The gate was never applied. `useSites()` was called unconditionally and `isAuthenticated` only guarded the derived `stores` value below it — and a gate on a derived value cannot hold a fetch that has already gone out. useResource has taken `Endpoint | null` for exactly this all along; the effect returns early on a null key, so nothing is sent. The gate goes in useSites rather than in one consumer because the rule belongs to the endpoint — no session, no estate — and /stores calls it too. Costs a signed-in user nothing: SessionProvider resolves `status` synchronously from the server-rendered `initialSession`, so there is no 'loading' pass to wait through before the request goes out. /stores is unaffected in the other direction too — AuthGuard renders a spinner instead of children once status is 'unauthenticated', so no consumer sits on a permanently held resource. Verified in the browser against a clean network buffer: with the gate reverted, /login issues GET /api/sites → 401; with it in place, /login issues 31 requests and none of them are /api/*. Worth being explicit about what this does NOT change: platform.loyaly.ai/api/* is the correct address for these calls. It is this app's own BFF, same-origin by design, and the hop to mcp.loyaly.ai happens server-side where the token lives. The 401 was a request that should never have been made, not a request made to the wrong host. Co-Authored-By: Claude Opus 5 (1M context) --- src/features/stores/hooks/useSites.ts | 21 ++++++++++++++++++++- src/shared/providers/WorkspaceProvider.tsx | 9 +++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/features/stores/hooks/useSites.ts b/src/features/stores/hooks/useSites.ts index cda7b3e..0538eb9 100644 --- a/src/features/stores/hooks/useSites.ts +++ b/src/features/stores/hooks/useSites.ts @@ -1,6 +1,7 @@ 'use client'; import {useResource} from '@/shared/hooks/useResource'; +import {useSession} from '@/features/auth/providers/SessionProvider'; import {siteRepository} from '@/features/stores/repositories/siteRepository'; import type {Site} from '@/features/stores/types/site'; import type {Resource} from '@/shared/hooks/useResource'; @@ -11,7 +12,25 @@ import type {Resource} from '@/shared/hooks/useResource'; * Unscoped on purpose — the site list is what the scope selector is FOR, so it * cannot itself be filtered by the selection. That also means one fetch per * session rather than one per store change. + * + * ── Why the session gate lives here ────────────────────────────────────── + * `GET /api/sites` is a BFF route, and every BFF route answers an anonymous + * request with 401 by design (bff.ts → NoSessionError). WorkspaceProvider sits + * ABOVE the whole route tree, /login included, so an ungated call here fired on + * the sign-in page and put a guaranteed `GET /api/sites 401 (Unauthorized)` in + * the console of every visitor who had not signed in yet — which reads as a + * broken API call and is in fact the BFF working exactly as specified. + * + * The gate belongs in this hook rather than in one consumer because the rule is + * a property of the ENDPOINT — no session, no estate — and there is more than + * one caller. Passing `null` is useResource's documented way to hold a request: + * the effect returns early, so nothing is sent at all. + * + * `status` is resolved synchronously from the server-rendered session (see + * SessionProvider's `initialSession`), so this costs a signed-in user nothing — + * there is no 'loading' pass to wait through before the estate is requested. */ export function useSites(): Resource { - return useResource(siteRepository.list()); + const {isAuthenticated} = useSession(); + return useResource(isAuthenticated ? siteRepository.list() : null); } diff --git a/src/shared/providers/WorkspaceProvider.tsx b/src/shared/providers/WorkspaceProvider.tsx index a6c6b0a..8352950 100644 --- a/src/shared/providers/WorkspaceProvider.tsx +++ b/src/shared/providers/WorkspaceProvider.tsx @@ -75,10 +75,11 @@ export function WorkspaceProvider({children}: {children: React.ReactNode}) { * five Bengaluru shops, which meant every scoped request in the app was * filtered by a store id that only existed in this file. * - * Gated on the session because this provider sits above the whole route - * tree, including /login: asking for the estate before anyone has signed in - * would put a guaranteed 401 in the console on every visit to the sign-in - * page. + * The session gate that keeps this off /login lives inside useSites, not + * here. It used to be written here as prose and applied only to `stores` + * below, so the request still went out on the sign-in page and every + * anonymous visitor got `GET /api/sites 401` in their console. A gate on the + * derived value cannot hold a fetch — only a null endpoint can. */ const {status} = useSession(); const sites = useSites();