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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 11:02:45 -07:00
parent e8c37b076e
commit 2f0f7c652f
5 changed files with 121 additions and 5 deletions

View File

@@ -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.

View File

@@ -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<void>((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) => {

View File

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

View File

@@ -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<void> {
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");

View File

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