From 515ece1691db4c55d5f2c213f973ba07a1fbfb99 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:45:11 +0200 Subject: [PATCH 1/2] fix(debug): hard-reload on TypeError so CF Access page-gate fires When the user lands on /debug via SPA client-side routing (clicking from elsewhere in the app), there is no server roundtrip to /debug, so the Cloudflare Access page-level intercept on anyplot.ai/debug is never triggered. The SPA mounts, then fires fetch('/api/debug/status', { credentials: 'include' }), which DOES hit CF Access on /api/debug/* and gets a 302 to anyplot.cloudflareaccess.com/cdn-cgi/access/login. Because the redirect target is cross-origin without CORS headers, fetch rejects with TypeError("Failed to fetch"), and the user sees "failed to load" until they hard-reload (which finally fires the page-level gate). Catch the TypeError once (per session) and trigger a top-level window.location.assign(window.location.href) to convert the SPA-routed entry into a real navigation. sessionStorage guard prevents looping when the second load also fails (allow-list misconfig, backend down, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/pages/DebugPage.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index b40ee132db..df088ee83c 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -208,6 +208,10 @@ export function DebugPage() { setError(null); adminFetch(`${DEBUG_API_URL}/debug/status`, adminToken) .then(async r => { + // Reaching the .then path means the fetch resolved cleanly — clear + // the one-shot reload guard so a future cross-origin redirect can + // re-trigger the bootstrap. + try { sessionStorage.removeItem('anyplot.debugAuthReloaded'); } catch {} // 403 is the Cloudflare Access JWT path's denial: a signed-in Google // account that isn't on the admin_allowed_emails allow-list. Surface // it on the auth-required screen with the server's message so the @@ -225,7 +229,25 @@ export function DebugPage() { return r.json(); }) .then(setData) - .catch(e => setError(e.message || 'failed to load')) + .catch(e => { + // SPA-routed entry to /debug bypasses the Cloudflare Access page- + // level intercept. The first API fetch then 302s cross-origin to + // *.cloudflareaccess.com, which fetch can't follow without CORS, + // surfacing as TypeError("Failed to fetch"). Force one top-level + // navigation so CF Access can intercept the page request and bounce + // to Google login. sessionStorage guard keeps this from looping if + // the second load ALSO fails (e.g. wrong allow-list). + if (e instanceof TypeError) { + let alreadyTried = false; + try { alreadyTried = !!sessionStorage.getItem('anyplot.debugAuthReloaded'); } catch {} + if (!alreadyTried) { + try { sessionStorage.setItem('anyplot.debugAuthReloaded', '1'); } catch {} + window.location.assign(window.location.href); + return; + } + } + setError(e.message || 'failed to load'); + }) .finally(() => setLoading(false)); }, [adminToken, reloadCounter]); From 4f61ffbb7f8bdda7e42a82db71bc94cdf8f001eb Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:58:27 +0200 Subject: [PATCH 2/2] review(debug): apply Copilot suggestions on auto-reload logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Copilot review on PR #5552, all four suggestions were sensible and applied: 1. Extract the magic string 'anyplot.debugAuthReloaded' into a single RELOAD_GUARD_KEY constant next to ADMIN_TOKEN_KEY. 2. Switch window.location.assign() → window.location.replace(). assign pushes a history entry, so the user could press Back and land on the pre-auth /debug state again, retriggering the loop. replace overwrites the current entry, which is what \"one-shot bootstrap\" actually means. 3. Add Vitest cases for the TypeError reload path — covers both the first-time-bootstrap branch (replace called, guard set) and the second-failure branch (replace NOT called, error surfaced). Resolves the codecov/patch/frontend FAILURE on the prior commit. 4. Reword the misleading \"fetch resolved cleanly\" comment in the .then handler to make clear that 401/403/503 also reach this branch. 10/10 tests green, tsc --noEmit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/pages/DebugPage.test.tsx | 40 ++++++++++++++++++++++++++++++++ app/src/pages/DebugPage.tsx | 18 +++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index 5b8262700a..740d0553ae 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -187,4 +187,44 @@ describe('DebugPage', () => { expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument(); }); + // The SPA-routed entry into /debug bypasses Cloudflare Access's page-level + // intercept (no server roundtrip = no 302 to login). The first API fetch + // then hits CF Access and 302s cross-origin to *.cloudflareaccess.com, + // which fetch can't follow without CORS, surfacing as TypeError. The page + // bootstraps the gate by triggering one top-level navigation. + it('forces a top-level navigation on TypeError and sets the reload guard', async () => { + sessionStorage.clear(); + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('Failed to fetch')))); + const replaceSpy = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, replace: replaceSpy, href: 'https://anyplot.ai/debug' }, + }); + + render(); + + await waitFor(() => { + expect(replaceSpy).toHaveBeenCalledTimes(1); + }); + expect(replaceSpy).toHaveBeenCalledWith('https://anyplot.ai/debug'); + expect(sessionStorage.getItem('anyplot.debugAuthReloaded')).toBe('1'); + }); + + it('does not loop: second TypeError after the guard is set surfaces the error', async () => { + sessionStorage.setItem('anyplot.debugAuthReloaded', '1'); + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('Failed to fetch')))); + const replaceSpy = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, replace: replaceSpy, href: 'https://anyplot.ai/debug' }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument(); + }); + expect(replaceSpy).not.toHaveBeenCalled(); + }); + }); diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index df088ee83c..5e2d1f7552 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -169,6 +169,8 @@ function pingColor(ms: number): string { // in sessionStorage so it survives reloads of the same tab without // persisting across browser sessions. const ADMIN_TOKEN_KEY = 'anyplot.adminToken'; +// One-shot guard for the SPA-routed → CF Access page-gate bootstrap. +const RELOAD_GUARD_KEY = 'anyplot.debugAuthReloaded'; const readAdminToken = (): string => { try { return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? ''; } catch { return ''; } }; @@ -208,10 +210,11 @@ export function DebugPage() { setError(null); adminFetch(`${DEBUG_API_URL}/debug/status`, adminToken) .then(async r => { - // Reaching the .then path means the fetch resolved cleanly — clear - // the one-shot reload guard so a future cross-origin redirect can + // Reaching here means the fetch promise resolved (the response may + // still be 401/403/503 — those are handled below). Clear the one-shot + // reload guard so a future cross-origin CF Access redirect can // re-trigger the bootstrap. - try { sessionStorage.removeItem('anyplot.debugAuthReloaded'); } catch {} + try { sessionStorage.removeItem(RELOAD_GUARD_KEY); } catch {} // 403 is the Cloudflare Access JWT path's denial: a signed-in Google // account that isn't on the admin_allowed_emails allow-list. Surface // it on the auth-required screen with the server's message so the @@ -239,10 +242,13 @@ export function DebugPage() { // the second load ALSO fails (e.g. wrong allow-list). if (e instanceof TypeError) { let alreadyTried = false; - try { alreadyTried = !!sessionStorage.getItem('anyplot.debugAuthReloaded'); } catch {} + try { alreadyTried = !!sessionStorage.getItem(RELOAD_GUARD_KEY); } catch {} if (!alreadyTried) { - try { sessionStorage.setItem('anyplot.debugAuthReloaded', '1'); } catch {} - window.location.assign(window.location.href); + try { sessionStorage.setItem(RELOAD_GUARD_KEY, '1'); } catch {} + // replace() not assign() — assign would push the broken pre-auth + // /debug onto the back-stack, so the user could navigate back + // into the same loop after logging in. + window.location.replace(window.location.href); return; } }