fix(stores): hold the estate fetch until there is a session

`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<T> | 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 19:22:00 +05:30
parent befe4307c3
commit a40afb9e7a
2 changed files with 25 additions and 5 deletions

View File

@@ -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<Site[]> {
return useResource(siteRepository.list());
const {isAuthenticated} = useSession();
return useResource(isAuthenticated ? siteRepository.list() : null);
}

View File

@@ -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();