diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index 591e6ce8d..1fe8643f0 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -21,6 +21,12 @@ export const RECENT_LOGIN_WINDOW = `${LOGIN_FRESH_AGE_MS / 60_000} minutes`; // an approval waits for its poll (the account writes it). export const ENROLLMENT_TTL_MS = 10 * 60 * 1000; +// Seconds a Burrow waits between enrollment polls (the relay answers it from +// begin): inside `MIN_ENROLL_POLL_INTERVAL_S`–`MAX_ENROLL_POLL_INTERVAL_S`, or +// every shipped Burrow refuses the begin. The account screen re-reads an +// approval's result at the same pace. +export const ENROLLMENT_POLL_INTERVAL_S = 5; + // The Hosted terms version the sign-in notice names and an account accepts by // continuing: the revision date both policy pages print // (`website/src/components/HostedPolicyLayout.tsx`), which diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index a68a75d36..59ad158df 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -57,7 +57,7 @@ import type { } from "remote-lib-common"; import type { RelayEnv } from "./bindings"; import { allowed } from "./one-time"; -import { ENROLLMENT_TTL_MS } from "./policy-constants"; +import { ENROLLMENT_POLL_INTERVAL_S, ENROLLMENT_TTL_MS } from "./policy-constants"; import { relayRoom } from "./relay-room-contract"; import { relayPushRoutes } from "./relay-push"; import { mintVoiceToken } from "./voice-token"; @@ -87,12 +87,6 @@ export const MAX_SESSIONS_PER_ACCOUNT = 32; /** One live registration challenge per live setup token of the same Burrow (rationale). */ export const MAX_SETUP_CHALLENGES_PER_BURROW = MAX_TOKENS_PER_BURROW; -/** - * Seconds a Burrow waits between polls: inside - * `MIN_ENROLL_POLL_INTERVAL_S`–`MAX_ENROLL_POLL_INTERVAL_S`, or every shipped - * Burrow refuses the begin. - */ -export const ENROLLMENT_POLL_INTERVAL_S = 5; /** * A device code's leading bytes: its expiry, big-endian epoch seconds; the * rest of its 32 are random. diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index cedf9afcb..7c480eb63 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -41,9 +41,8 @@ import { } from "../../../remote-lib-common/test/harness/actors.mjs"; import { ADMIN_EMAIL } from "../entitlement"; import { migrations } from "../migrations"; -import { ENROLLMENT_TTL_MS } from "../policy-constants"; +import { ENROLLMENT_POLL_INTERVAL_S, ENROLLMENT_TTL_MS } from "../policy-constants"; import { - ENROLLMENT_POLL_INTERVAL_S, MAX_BURROWS_PER_ACCOUNT, MAX_PASSKEYS_PER_ACCOUNT, MAX_SESSIONS_PER_ACCOUNT, diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts new file mode 100644 index 000000000..269a7698b --- /dev/null +++ b/hosted/server/tests/settle.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ReadFailed, + afterRead, + approvalShows, + asRead, + keepRead, + pageError, + preLaunchNotice, + reread, + settle, + type AccountState, +} from "../../src/settle"; + +const computer = (burrowId: string) => ({ burrowId, enrolledAt: "2026-10-20" }); +const state = (on: boolean | null, ids: string[] | null): AccountState => ({ + voice: on === null ? null : { entitled: true, on }, + computers: ids && ids.map(computer), +}); + +describe("approvalShows", () => { + it("waits on a voice approval until Natural Voice is on", () => { + const shows = approvalShows("voice", state(false, []))!; + expect(shows(state(false, []))).toBe(false); + expect(shows(state(null, []))).toBe(false); + expect(shows(state(true, []))).toBe(true); + }); + + it("has nothing to wait on when Natural Voice is already on", () => { + expect(approvalShows("voice", state(true, []))).toBeNull(); + }); + + it("waits on a remote approval until a computer not listed before appears", () => { + const shows = approvalShows("remote", state(false, ["a", "b"]))!; + expect(shows(state(false, ["a", "b"]))).toBe(false); + expect(shows(state(false, ["a"]))).toBe(false); + expect(shows(state(false, null))).toBe(false); + expect(shows(state(false, ["c", "a"]))).toBe(true); + }); + + it("has nothing to compare a remote approval with when no list was read", () => { + expect(approvalShows("remote", state(false, null))).toBeNull(); + }); +}); + +describe("preLaunchNotice", () => { + it("shows only once a lookup succeeded and found no login", () => { + expect(preLaunchNotice("signed-out", false)).toBe(true); + expect(preLaunchNotice("signed-in", false)).toBe(false); + expect(preLaunchNotice("failed", false)).toBe(false); + expect(preLaunchNotice("pending", false)).toBe(false); + }); + + it("never replaces a sign-in form the URL opened", () => { + expect(preLaunchNotice("signed-out", true)).toBe(false); + }); +}); + +describe("read errors", () => { + it("marks a failed read's error as one, keeping its message", async () => { + await expect(asRead(Promise.resolve(1))).resolves.toBe(1); + const failed = await asRead(Promise.reject(new Error("offline"))).catch( + (error: unknown) => error, + ); + expect(failed).toBeInstanceOf(ReadFailed); + expect(pageError(failed)).toEqual({ message: "offline", read: true }); + }); + + it("marks an action's error as not a read's", () => { + expect(pageError(new Error("Computer not found."))).toEqual({ + message: "Computer not found.", + read: false, + }); + expect(pageError("thrown")).toEqual({ + message: "Something went wrong. Please try again.", + read: false, + }); + }); + + it("keeps only a failed read's error through a step that reads nothing", () => { + const read = { message: "offline", read: true }; + expect(keepRead(read)).toBe(read); + expect(keepRead({ message: "Computer not found.", read: false })).toBeNull(); + expect(keepRead(null)).toBeNull(); + }); + + it("clears only a failed read's error on a successful read", () => { + const action = { message: "Computer not found.", read: false }; + expect(afterRead({ message: "offline", read: true })).toBeNull(); + expect(afterRead(action)).toBe(action); + expect(afterRead(null)).toBeNull(); + }); +}); + +describe("reread", () => { + it("resolves the read, telling nothing failed", async () => { + const failed = vi.fn(); + await expect(reread(async () => 1, failed)).resolves.toBe(1); + expect(failed).not.toHaveBeenCalled(); + }); + + it("reports a failed read itself and resolves, so the mutation stays a success", async () => { + const failed = vi.fn(); + await expect( + reread(() => Promise.reject(new Error("offline")), failed), + ).resolves.toBeUndefined(); + expect(failed).toHaveBeenCalledOnce(); + }); +}); + +describe("settle", () => { + const options = (cancelled = () => false) => ({ + intervalMs: 5000, + timeoutMs: 20_000, + cancelled, + wait: vi.fn(async () => {}), + }); + + it("reads after each interval until a read shows the change", async () => { + const read = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2); + const opts = options(); + // Never handed a missed read: `toFixed` throws on one. + const done = (value: number) => value.toFixed() === "2"; + await expect(settle(read, done, opts)).resolves.toBe("done"); + expect(read).toHaveBeenCalledTimes(4); + expect(opts.wait).toHaveBeenCalledTimes(4); + expect(opts.wait).toHaveBeenCalledWith(5000); + }); + + it("gives up once the timeout's reads all missed", async () => { + const read = vi.fn(async () => 0); + await expect(settle(read, () => false, options())).resolves.toBe("timeout"); + expect(read).toHaveBeenCalledTimes(4); + }); + + it("stops without reading again once cancelled", async () => { + const unread = vi.fn(async () => 1); + await expect(settle(unread, () => true, options(() => true))).resolves.toBe( + "cancelled", + ); + expect(unread).not.toHaveBeenCalled(); + let cancelled = false; + const read = vi.fn(async () => { + cancelled = true; + return 1; + }); + await expect( + settle(read, () => true, options(() => cancelled)), + ).resolves.toBe("cancelled"); + expect(read).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 2b7816ee1..7839ec7c1 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -28,11 +28,25 @@ import { type Session, } from "./api"; import { + ENROLLMENT_POLL_INTERVAL_S, LOGIN_FRESH_AGE_MS, RECENT_LOGIN_WINDOW, TERMS_VERSION, } from "../server/policy-constants"; import { takeEnrollment, type Enrollment } from "./enrollment"; +import { + afterRead, + approvalShows, + asRead, + keepRead, + pageError, + preLaunchNotice, + reread, + settle, + type AccountState, + type Lookup, + type PageError, +} from "./settle"; // The terms version the sign-in notice showed when this tab continued past it, // kept through a provider's redirect until the account's acceptance is @@ -67,6 +81,18 @@ const SUBSCRIBE_REDIRECT_MS = 4000; const SIGN_IN_PARAMS = new URLSearchParams(location.search); const SIGN_IN_OPEN = SIGN_IN_PARAMS.has("signin") || SIGN_IN_PARAMS.has("error"); +/** A change Dormouse on the computer finishes after the page's mutation. */ +interface Pending { + /** Whether the account, as last read, shows the change. */ + shows: (after: AccountState) => boolean; + waiting: string; + done: string; + late: string; +} +// How long the page re-reads for a Pending before saying it has not shown; +// after that the focus refresh picks it up. +const PENDING_TIMEOUT_MS = 60_000; + export function App({ enrollment }: { enrollment: Enrollment | null }) { const [session, setSession] = useState(null); const [enabled, setEnabled] = useState([]); @@ -87,8 +113,9 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setEnrollingState(next); }; const [loading, setLoading] = useState(true); + const [lookup, setLookup] = useState("pending"); const [busy, setBusy] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(null); const [notice, setNotice] = useState(""); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); @@ -96,20 +123,28 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const codeInput = useRef(null); const actionPending = useRef(false); const refreshGeneration = useRef(0); + // Bumped by every action and on unmount: stops re-reading for a Pending. + const actionGeneration = useRef(0); + // A Pending whose late notice is up: a later read that shows it replaces it. + const latePending = useRef(null); const callbackError = useRef( new URLSearchParams(location.search).has("error"), ); - const refresh = useCallback(async () => { + // Undefined when a newer refresh superseded this one. + const refresh = useCallback(async (): Promise => { const generation = ++refreshGeneration.current; - const [current, providers] = await Promise.all([ - getSession(), - getProviders(), - ]); + const failed = (error: unknown): never => { + if (generation === refreshGeneration.current) setLookup("failed"); + throw error; + }; + const [current, providers] = await asRead( + Promise.all([getSession(), getProviders()]), + ).catch(failed); // A failed list hides its section, never the account page. const [linked, voice, enrolled, registered] = current ? await Promise.all([ - getAccounts(), + asRead(getAccounts()).catch(failed), getHostedVoice().catch(() => null), getComputers().catch(() => null), getPasskeys().catch(() => null), @@ -117,6 +152,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { : [[], null, null, null]; if (current) await recordContinued().catch(() => {}); if (generation !== refreshGeneration.current) return; + setError(afterRead); + setLookup(current ? "signed-in" : "signed-out"); setSession(current); setEnabled(providers); setAccounts(linked); @@ -128,35 +165,53 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { "", enrollingRef.current ? "/enroll" : current ? "/account" : "/login", ); + const state = { voice, computers: enrolled }; + const late = latePending.current; + if (late?.shows(state)) { + latePending.current = null; + setNotice(late.done); + } + return state; }, []); useEffect(() => { // An auth callback's query parameters (`?error=`) never stay in history. history.replaceState(null, "", location.pathname); void refresh() - .catch((error) => setError(error.message)) + .catch((error) => setError(pageError(error))) .finally(() => { setLoading(false); if (callbackError.current) - setError( - "Sign-in or connection was not completed. Try again, or sign in with your existing method and connect the provider from your account.", - ); + setError({ + message: + "Sign-in or connection was not completed. Try again, or sign in with your existing method and connect the provider from your account.", + read: false, + }); }); const focus = () => { if (!actionPending.current) - void refresh().catch((error) => setError(error.message)); + void refresh().catch((error) => setError(pageError(error))); + }; + // Returning to the tab: mobile Safari fires no focus coming back from + // another app. + const visible = () => { + if (document.visibilityState === "visible") focus(); }; window.addEventListener("focus", focus); + document.addEventListener("visibilitychange", visible); return () => { ++refreshGeneration.current; + ++actionGeneration.current; window.removeEventListener("focus", focus); + document.removeEventListener("visibilitychange", visible); }; }, [refresh]); useEffect(() => { if (enterCode) codeInput.current?.focus(); }, [enterCode]); // A failed lookup keeps its error and Retry on screen rather than sending - // a member who is signed in away during an outage. - const comingSoon = !loading && !session && !error && !SIGN_IN_OPEN; + // a member who is signed in away during an outage, even once that error is + // cleared (a new enrollment link, Don't approve). + const comingSoon = preLaunchNotice(lookup, SIGN_IN_OPEN); useEffect(() => { if (!comingSoon) return; const timer = setTimeout( @@ -173,33 +228,62 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const next = takeEnrollment(); if (!next) return; setEnrolling(next); - setError(""); + setError(keepRead); setNotice(""); }; window.addEventListener("hashchange", takeLink); return () => window.removeEventListener("hashchange", takeLink); }, []); - async function act(label: string, action: () => Promise) { + async function act(label: string, action: () => Promise) { if (actionPending.current) return; ++refreshGeneration.current; + ++actionGeneration.current; + latePending.current = null; actionPending.current = true; setBusy(label); - setError(""); + setError(null); setNotice(""); try { await action(); } catch (error) { - setError( - error instanceof Error - ? error.message - : "Something went wrong. Please try again.", - ); + setError(pageError(error)); } finally { actionPending.current = false; setBusy(""); } } + /** + * Runs a mutation of the account, then shows the account as the server now + * has it. A Pending it returns is re-read in the background, at the + * computer's poll pace, until the account shows it. A failed refetch never + * reports the mutation, which succeeded, as failed. + */ + const mutate = (label: string, action: () => Promise) => + act(label, async () => { + const pending = await action(); + const after = await reread(refresh, () => + setError({ + message: + "Your change is saved, but this page could not reload to show it. Retry, or come back to this page later.", + read: true, + }), + ); + if (pending) void follow(pending, after); + }); + async function follow(pending: Pending, after: AccountState | undefined) { + if (after && pending.shows(after)) return setNotice(pending.done); + const generation = actionGeneration.current; + setNotice(pending.waiting); + const outcome = await settle(refresh, pending.shows, { + intervalMs: ENROLLMENT_POLL_INTERVAL_S * 1000, + timeoutMs: PENDING_TIMEOUT_MS, + cancelled: () => generation !== actionGeneration.current, + }); + if (outcome === "cancelled") return; + if (outcome === "timeout") latePending.current = pending; + setNotice(outcome === "done" ? pending.done : pending.late); + } const sendCode = () => act("email", async () => { await post("email-otp/send-verification-otp", { @@ -216,16 +300,15 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { void sendCode(); return; } - void act("verify", async () => { + void mutate("verify", async () => { noteContinued(); await post("sign-in/email-otp", { email: email.trim(), otp: code }); setCode(""); setEnterCode(false); - await refresh(); }); } const signOut = () => - act("logout", async () => { + mutate("logout", async () => { await post("sign-out"); setSession(null); setAccounts([]); @@ -234,61 +317,60 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setPasskeys(null); setEnterCode(false); setCode(""); - await refresh(); setNotice( "Signed out of this browser. Your other devices stay signed in.", ); }); const turnOffVoice = () => - act("voice-off", async () => { + mutate("voice-off", async () => { try { await turnOffHostedVoice(); } finally { // Closed either way, so a failure's error shows on the page. turnOffDialog.current?.close(); } - setHostedVoice((voice) => voice && { ...voice, on: false }); setNotice("Natural Voice is off. Your computers use the system voice."); }); const approve = (code: string) => - act("approve", async () => { + mutate("approve", async () => { + const voice = enrolling?.purpose === "voice"; + // The computer's next poll redeems the approval: until then neither + // Natural Voice nor the computer shows on the account. + const shows = approvalShows(voice ? "voice" : "remote", { + voice: hostedVoice, + computers, + }); await approveEnrollment(code, enrolling?.purpose); clearEnrollment(); - await refresh(); - setNotice( - `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, - ); + const later = + "Keep Dormouse open on your computer; this page updates when you come back to it."; + if (!shows) + return setNotice( + `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, + ); + return voice + ? { + shows, + waiting: `Approved ${code}. Waiting for Dormouse on your computer to turn on Natural Voice…`, + done: "Natural Voice is on.", + late: `Approved ${code}, but Dormouse on your computer has not turned on Natural Voice yet. ${later}`, + } + : { + shows, + waiting: `Approved ${code}. Waiting for Dormouse on your computer to finish enrolling…`, + done: "Your computer is enrolled.", + late: `Approved ${code}, but Dormouse on your computer has not finished enrolling yet. ${later}`, + }; }); const clearEnrollment = () => { setEnrolling(null); - setError(""); + setError(keepRead); history.replaceState(null, "", session ? "/account" : "/login"); }; const removeOne = (burrowId: string) => - act(`remove-${burrowId}`, async () => { - await removeComputer(burrowId); - setComputers( - (enrolled) => - enrolled?.filter((computer) => computer.burrowId !== burrowId) ?? - null, - ); - // Its passkeys stay, no longer attributed to it. - setPasskeys( - (registered) => - registered?.map((passkey) => - passkey.burrowId === burrowId ? { ...passkey, burrowId: null } : passkey, - ) ?? null, - ); - }); + mutate(`remove-${burrowId}`, () => removeComputer(burrowId)); const removeKey = (credentialId: string) => - act(`passkey-${credentialId}`, async () => { - await removePasskey(credentialId); - setPasskeys( - (registered) => - registered?.filter((passkey) => passkey.credentialId !== credentialId) ?? - null, - ); - }); + mutate(`passkey-${credentialId}`, () => removePasskey(credentialId)); // A lapsed account still sees, and may turn off, a Natural Voice left on. const voiceShown = !!hostedVoice && (hostedVoice.entitled || hostedVoice.on); const fresh = @@ -342,7 +424,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { <> {error && (
-

{error}

+

{error.message}