From 2f0f7c652fce9423dbd0784fe83a22a57ddb192f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 11:02:45 -0700 Subject: [PATCH] FN-8301: restore PWA token recovery after cold starts Restore the auth-token dialog when an installed PWA receives a daemon 401 before React mounts. - Latch daemon authentication failures for mount-time recovery. - Clear the latch when the authentication token changes. - Cover cold-start recovery and successful-response behavior with dashboard tests. Files changed: .changeset/fn-8301-pwa-auth-recovery.md | 7 +++ packages/dashboard/app/__tests__/auth.test.ts | 40 ++++++++++++++ packages/dashboard/app/auth.ts | 8 +++ .../hooks/__tests__/useAuthTokenRecovery.test.ts | 61 +++++++++++++++++++++- .../dashboard/app/hooks/useAuthTokenRecovery.ts | 10 ++-- 5 files changed, 121 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8301 Fusion-Task-Lineage: b35236f4-af3d-4313-8b7d-6cd59a237144 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8301-pwa-auth-recovery.md | 7 +++ packages/dashboard/app/__tests__/auth.test.ts | 40 ++++++++++++ packages/dashboard/app/auth.ts | 8 +++ .../__tests__/useAuthTokenRecovery.test.ts | 61 ++++++++++++++++++- .../app/hooks/useAuthTokenRecovery.ts | 10 ++- 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-8301-pwa-auth-recovery.md diff --git a/.changeset/fn-8301-pwa-auth-recovery.md b/.changeset/fn-8301-pwa-auth-recovery.md new file mode 100644 index 0000000000..f87b150d88 --- /dev/null +++ b/.changeset/fn-8301-pwa-auth-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore token recovery for installed PWAs after an unauthorized backend response. +category: fix +dev: Recovery reads the latched daemon-auth failure when the dashboard hook mounts. diff --git a/packages/dashboard/app/__tests__/auth.test.ts b/packages/dashboard/app/__tests__/auth.test.ts index fe2968a2a3..6966e2730a 100644 --- a/packages/dashboard/app/__tests__/auth.test.ts +++ b/packages/dashboard/app/__tests__/auth.test.ts @@ -201,6 +201,46 @@ describe("installAuthFetch", () => { window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, eventHandler); }); + it("reports daemon-auth failures through the getter and clears it with token changes", async () => { + window.fetch = vi.fn(async () => new Response( + JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }), + { status: 401, headers: { "content-type": "application/json" } }, + )) as unknown as typeof window.fetch; + + const { + AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + clearAuthToken, + hasDaemonAuthFailure, + installAuthFetch, + setAuthToken, + } = await loadAuthModule(); + installAuthFetch(); + + const waitForRecovery = () => new Promise((resolve) => { + const handleRecovery = () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + resolve(); + }; + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + }); + + const firstRecovery = waitForRecovery(); + await fetch("/api/tasks"); + await firstRecovery; + expect(hasDaemonAuthFailure()).toBe(true); + + setAuthToken("replacement-token"); + expect(hasDaemonAuthFailure()).toBe(false); + + const secondRecovery = waitForRecovery(); + await fetch("/api/tasks?retry=1"); + await secondRecovery; + expect(hasDaemonAuthFailure()).toBe(true); + + clearAuthToken(); + expect(hasDaemonAuthFailure()).toBe(false); + }); + it("fires recovery only for the exact daemon-auth 401 shape on same-origin /api requests", async () => { window.localStorage.setItem("fn.authToken", "stale-token"); const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { diff --git a/packages/dashboard/app/auth.ts b/packages/dashboard/app/auth.ts index e46a84f36e..cd2dc03b03 100644 --- a/packages/dashboard/app/auth.ts +++ b/packages/dashboard/app/auth.ts @@ -110,6 +110,14 @@ export function getAuthToken(): string | undefined { return undefined; } +/* +FNXC:AuthTokenRecovery 2026-07-14-00:00: +Cold PWA and bare-URL starts can receive the one-shot daemon-auth 401 recovery event before React mounts its listener. Keep the latched failure queryable so the recovery hook can open the token dialog on mount instead of stranding the user on the backend error page. +*/ +export function hasDaemonAuthFailure(): boolean { + return daemonAuthFailureSignaled; +} + /** Persist a token for future dashboard API requests in this browser session. */ export function setAuthToken(token: string): void { cachedToken = token; diff --git a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts index 0c8f175b71..4ad4a3bbf5 100644 --- a/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAuthTokenRecovery.test.ts @@ -1,9 +1,35 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; +import { + AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, + clearAuthToken, + hasDaemonAuthFailure, + installAuthFetch, + setAuthToken, +} from "../../auth"; import { useAuthTokenRecovery } from "../useAuthTokenRecovery"; +const originalFetch = window.fetch; + +function waitForDaemonAuthRecoveryEvent(): Promise { + return new Promise((resolve) => { + const handleRecovery = () => { + window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + resolve(); + }; + window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery); + }); +} + describe("useAuthTokenRecovery", () => { + beforeEach(() => { + clearAuthToken(); + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + window.fetch = originalFetch; + delete (window as Window & { __fnAuthFetchInstalled?: boolean }).__fnAuthFetchInstalled; + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -25,6 +51,37 @@ describe("useAuthTokenRecovery", () => { expect(result.current.open).toBe(true); }); + it("opens when a daemon-auth 401 latched before the hook mounts", async () => { + window.fetch = vi.fn(async () => new Response( + JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }), + { status: 401, headers: { "content-type": "application/json" } }, + )) as unknown as typeof window.fetch; + installAuthFetch(); + + const recoveryEvent = waitForDaemonAuthRecoveryEvent(); + await fetch("/api/tasks"); + await recoveryEvent; + + expect(hasDaemonAuthFailure()).toBe(true); + const { result } = renderHook(() => useAuthTokenRecovery()); + expect(result.current.open).toBe(true); + }); + + it("does not open for a successful API response with a valid token", async () => { + setAuthToken("valid-token"); + window.fetch = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof window.fetch; + installAuthFetch(); + + await fetch("/api/tasks"); + + expect(hasDaemonAuthFailure()).toBe(false); + const { result } = renderHook(() => useAuthTokenRecovery()); + expect(result.current.open).toBe(false); + }); + it("removes the daemon auth-failure listener on unmount", () => { const addSpy = vi.spyOn(window, "addEventListener"); const removeSpy = vi.spyOn(window, "removeEventListener"); diff --git a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts index e799face87..8d49ff788a 100644 --- a/packages/dashboard/app/hooks/useAuthTokenRecovery.ts +++ b/packages/dashboard/app/hooks/useAuthTokenRecovery.ts @@ -1,10 +1,10 @@ /* -FNXC:AuthTokenRecovery 2026-06-24-00:00: -App-level open state for the auth-token recovery dialog, opened when the daemon signals auth failure (AUTH_TOKEN_RECOVERY_REQUIRED_EVENT). Extracted verbatim from AppInner. +FNXC:AuthTokenRecovery 2026-07-14-00:00: +App-level open state for the auth-token recovery dialog follows the daemon's auth-failure event and its latch. Cold PWA and bare-URL 401s can fire before React mounts this listener, so the mount-time latch read must open recovery for that missed one-shot event. */ import { useEffect, useState } from "react"; -import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../auth"; +import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, hasDaemonAuthFailure } from "../auth"; export interface UseAuthTokenRecoveryResult { open: boolean; @@ -19,6 +19,10 @@ export function useAuthTokenRecovery(): UseAuthTokenRecoveryResult { }; window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); + if (hasDaemonAuthFailure()) { + setOpen(true); + } + return () => { window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleDaemonAuthFailure); };