Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hosted/server/policy-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions hosted/server/relay-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions hosted/server/tests/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions hosted/server/tests/settle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { approvalShows, 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("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<number | undefined>>()
.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);
});
});
125 changes: 89 additions & 36 deletions hosted/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ 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 { approvalShows, reread, settle, type AccountState } 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
Expand Down Expand Up @@ -67,6 +69,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<Session | null>(null);
const [enabled, setEnabled] = useState<Provider[]>([]);
Expand Down Expand Up @@ -96,11 +110,14 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
const codeInput = useRef<HTMLInputElement>(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);
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<AccountState | undefined> => {
const generation = ++refreshGeneration.current;
const [current, providers] = await Promise.all([
getSession(),
Expand Down Expand Up @@ -128,6 +145,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
"",
enrollingRef.current ? "/enroll" : current ? "/account" : "/login",
);
return { voice, computers: enrolled };
}, []);
useEffect(() => {
// An auth callback's query parameters (`?error=`) never stay in history.
Expand All @@ -145,10 +163,18 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
if (!actionPending.current)
void refresh().catch((error) => setError(error.message));
};
// 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(() => {
Expand Down Expand Up @@ -180,9 +206,10 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
return () => window.removeEventListener("hashchange", takeLink);
}, []);

async function act(label: string, action: () => Promise<void>) {
async function act(label: string, action: () => Promise<unknown>) {
if (actionPending.current) return;
++refreshGeneration.current;
++actionGeneration.current;
actionPending.current = true;
setBusy(label);
setError("");
Expand All @@ -200,6 +227,34 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
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<Pending | void>) =>
act(label, async () => {
const pending = await action();
const after = await reread(refresh, () =>
setError(
Comment thread
dormouse-bot marked this conversation as resolved.
Outdated
"Your change is saved, but this page could not reload to show it. Retry, or come back to this page later.",
),
);
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")
setNotice(outcome === "done" ? pending.done : pending.late);
}
const sendCode = () =>
act("email", async () => {
await post("email-otp/send-verification-otp", {
Expand All @@ -216,16 +271,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([]);
Expand All @@ -234,61 +288,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}`,
Comment thread
dormouse-bot marked this conversation as resolved.
}
: {
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}`,
Comment thread
dormouse-bot marked this conversation as resolved.
};
});
const clearEnrollment = () => {
setEnrolling(null);
setError("");
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 =
Expand Down
Loading
Loading