From 5bbdd5c63b025ef3d7b23646332709becc18e2bc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 2 Nov 2026 22:56:16 +1100 Subject: [PATCH] fix(desktop): bound and cancel invite-handoff join policy discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A receipt-less buzz://join claim first discovers the relay's join policy through the webview fetch path, which had no deadline, no body cap, and no way to cancel. A relay whose /api/join-policy blackholed, trickled, or streamed an oversized body left onboarding on "Accepting your invite…" forever, and cancelling did not stop the download. The webview branch of getJoinPolicy now matches the native fetch_join_policy bounds: one 15 s deadline covers the headers and the whole body, and the body is capped at 4 MiB while it streams (a larger declared Content-Length fails up front). A timeout surfaces as "The community's join policy took too long to load.", which the claiming stage shows with Retry. getJoinPolicy takes an optional AbortSignal, and useClaimInvite aborts the pending lookup when its transaction is cancelled, replaced, or unmounted. The existing transaction fence still drops any late settlement. The useClaimInvite jsdom suite gains a journey with a suspended /api/join-policy: no error at 14.999 s, a retryable timeout at 15 s with zero claims, then a retry that claims once against a policy-free relay. The single cancel test becomes a cancel/reopen table. Each row asserts the lookup's signal is aborted and that a late policy response neither claims nor moves the cleared or replacement transaction. A getJoinPolicy test rejects both a streamed and a declared body over 4 MiB. Removing the abort, the fence, the timer, or the cap each fails its test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../onboarding/useClaimInvite.jsdom-test.mjs | 120 +++++++++++++++--- .../src/features/onboarding/useClaimInvite.ts | 9 +- desktop/src/shared/api/invites.test.mjs | 27 ++++ desktop/src/shared/api/invites.ts | 81 ++++++++++-- 4 files changed, 203 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs index a7420ed756..0f4f3c846d 100644 --- a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs +++ b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { createRoot } from "react-dom/client"; @@ -200,32 +200,38 @@ test("policy lookup failure never claims and can be retried", async () => { } }); -test("cancelled policy discovery cannot claim after its response arrives", async () => { +test("suspended policy discovery times out retryably without claiming", async () => { localStorage.clear(); const original = globalThis.fetch; - let resolvePolicy; + let lookups = 0; let started; const lookupStarted = new Promise((resolve) => { started = resolve; }); - const response = new Promise((resolve) => { - resolvePolicy = resolve; - }); - let claims = 0; - globalThis.fetch = async (url) => { + const claimReceipts = []; + globalThis.fetch = async (url, options) => { if (String(url).endsWith("/api/join-policy")) { + lookups++; + if (lookups > 1) return Response.json({}); started(); - return response; + // A blackholed relay: nothing settles until the request is aborted. + return new Promise((_, reject) => + options.signal.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ), + ); } - claims++; - throw new Error("unexpected claim"); - }; - try { - startCommunityOnboarding({ - source: "deep-link-join", - relayUrl: "wss://relay.example", - inviteCode: "code", + claimReceipts.push(JSON.parse(options.body).policy_receipt); + return Response.json({ + status: "joined", + community_id: "id", + host: "relay.example", + role: "member", }); + }; + mock.timers.enable({ apis: ["setTimeout"] }); + try { + startCommunityOnboarding(linkJoin); render( React.createElement( CommunityOnboardingProvider, @@ -234,16 +240,88 @@ test("cancelled policy discovery cannot claim after its response arrives", async ), ); await lookupStarted; - await act(async () => controls.clear()); - await act(async () => resolvePolicy(Response.json({}))); - assert.equal(controls.transaction, null); - assert.equal(claims, 0); + await act(async () => mock.timers.tick(14_999)); + assert.equal(controls.transaction.error, undefined); + await act(async () => mock.timers.tick(1)); + mock.timers.reset(); + await waitFor(() => + assert.equal( + controls.transaction.error, + "The community's join policy took too long to load.", + ), + ); + assert.equal(controls.transaction.stage, "claiming"); + assert.deepEqual(claimReceipts, []); + // Retry, as the claiming stage's button does; this relay has no policy. + await act(async () => controls.update({ error: undefined })); + await waitFor(() => assert.equal(controls.transaction.stage, "connecting")); + assert.equal(lookups, 2); + assert.deepEqual(claimReceipts, [undefined]); } finally { + mock.timers.reset(); cleanup(); globalThis.fetch = original; } }); +// Cancelling, or reopening the link mid-lookup, aborts the pending discovery. +// Its late settlement (here a response that ignores the abort) must not move +// the cleared or replacement transaction. +for (const [name, interrupt] of [ + ["cancelled", () => controls.clear()], + ["reopened", () => controls.start(linkJoin)], +]) { + test(`${name} policy discovery is aborted and cannot settle afterwards`, async () => { + localStorage.clear(); + const original = globalThis.fetch; + const signals = []; + let resolvePolicy; + let started; + const lookupStarted = new Promise((resolve) => { + started = resolve; + }); + let claims = 0; + globalThis.fetch = async (url, options) => { + if (String(url).endsWith("/api/join-policy")) { + signals.push(options.signal); + if (signals.length > 1) return new Promise(() => {}); + started(); + return new Promise((resolve) => { + resolvePolicy = resolve; + }); + } + claims++; + throw new Error("unexpected claim"); + }; + try { + startCommunityOnboarding(linkJoin); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await lookupStarted; + await act(async () => interrupt()); + assert.equal(signals[0].aborted, true); + await act(async () => resolvePolicy(Response.json({ policy }))); + if (name === "cancelled") { + assert.equal(controls.transaction, null); + } else { + await waitFor(() => assert.equal(signals.length, 2)); + assert.equal(signals[1].aborted, false); + assert.equal(controls.transaction.stage, "claiming"); + assert.equal(controls.transaction.error, undefined); + } + assert.equal(claims, 0); + } finally { + cleanup(); + globalThis.fetch = original; + } + }); +} + for (const [sentinel, guidance] of [ [ "invite_restricted", diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts index 206031c7a7..700ce07d33 100644 --- a/desktop/src/features/onboarding/useClaimInvite.ts +++ b/desktop/src/features/onboarding/useClaimInvite.ts @@ -11,7 +11,8 @@ import { claimInvite, getJoinPolicy } from "@/shared/api/invites"; * Drive the `claiming` stage after machine onboarding completes: claim the * invite with the user's final identity, then advance to `connecting`. * Completion is fenced by transaction ID so cancelling or replacing the - * transaction while the request is pending cannot mutate the replacement. + * transaction while the request is pending cannot mutate the replacement; + * the same change aborts a pending policy lookup. * * The error guard keeps a failed claim parked on the caller's Retry * affordance — without it the effect refires on the error-bearing transaction @@ -21,10 +22,12 @@ export function useClaimInvite() { const { transaction, update } = useCommunityOnboarding(); const [isPending, setIsPending] = React.useState(false); const currentTransaction = React.useRef(transaction); + const policyLookup = React.useRef(null); React.useEffect(() => { currentTransaction.current = transaction; return () => { currentTransaction.current = null; + policyLookup.current?.abort(); }; }, [transaction]); @@ -33,9 +36,11 @@ export function useClaimInvite() { return; } setIsPending(true); + const lookup = new AbortController(); + policyLookup.current = lookup; void (async () => { const policy = !transaction.policyReceipt - ? await getJoinPolicy(transaction.relayUrl, "webview") + ? await getJoinPolicy(transaction.relayUrl, "webview", lookup.signal) : null; if (currentTransaction.current !== transaction) return; if (policy) { diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs index eb3754435e..b4459b13a7 100644 --- a/desktop/src/shared/api/invites.test.mjs +++ b/desktop/src/shared/api/invites.test.mjs @@ -53,6 +53,33 @@ test("getJoinPolicy fails closed on a policy endpoint error", async () => { ); }); +test("getJoinPolicy refuses a webview response past the 4 MiB native cap", async () => { + const oversized = 4 * 1024 * 1024 + 1; + // Streamed without a length, then declared up front. + const streamed = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"policy":null,"pad":"')); + controller.enqueue(new Uint8Array(oversized).fill(0x61)); + controller.enqueue(new TextEncoder().encode('"}')); + controller.close(); + }, + }); + for (const response of [ + new Response(streamed, { status: 200 }), + new Response("{}", { + status: 200, + headers: { "content-length": String(oversized) }, + }), + ]) { + await withFetch(response, () => + assert.rejects( + getJoinPolicy("wss://relay.example", "webview"), + /oversized join policy/, + ), + ); + } +}); + test("getJoinPolicy maps the native command response", async () => { const previousWindow = globalThis.window; globalThis.window = { diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts index d0a3c2ed2c..6614e5c2b9 100644 --- a/desktop/src/shared/api/invites.ts +++ b/desktop/src/shared/api/invites.ts @@ -21,6 +21,11 @@ const NIP98_KIND = 27235; // connect timeout (a minute or more on macOS). const INVITE_REQUEST_TIMEOUT_MS = 15_000; +// Matches the native fetch_join_policy cap: each relay policy document is at +// most 256 KiB, and four MiB covers two maximally escaped documents plus the +// response envelope. +const MAX_JOIN_POLICY_RESPONSE_BYTES = 4 * 1024 * 1024; + export type MintedInvite = { code: string; expiresAt: number; @@ -127,10 +132,42 @@ export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean { } } -/** Fetch relay-hosted policy content for any join surface. */ +/** Read a JSON body, refusing it once it streams past `maxBytes`. */ +async function readBoundedJson( + response: Response, + maxBytes: number, +): Promise { + if (Number(response.headers.get("content-length")) > maxBytes) { + throw new Error("relay returned oversized join policy"); + } + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + void reader.cancel(); + throw new Error("relay returned oversized join policy"); + } + text += decoder.decode(value, { stream: true }); + } + return JSON.parse(text + decoder.decode()); +} + +/** + * Fetch relay-hosted policy content for any join surface. + * + * The webview transport mirrors the native command's bounds: one deadline + * covers the headers and the whole body, and the body is capped while it + * streams. `signal` lets the caller abandon the request early. + */ export async function getJoinPolicy( relayWsUrl: string, transport: "native" | "webview", + signal?: AbortSignal, ): Promise { type RawJoinPolicy = { terms_markdown?: string; @@ -145,16 +182,38 @@ export async function getJoinPolicy( }); } else { const base = relayHttpFromWs(relayWsUrl); - const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`); - // Relays predating join-policy support have no configured policy. - if (response.status === 404) return null; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - raw = - ( - (await response.json()) as { - policy?: RawJoinPolicy; - } - ).policy ?? null; + const controller = new AbortController(); + const abort = () => controller.abort(); + signal?.addEventListener("abort", abort, { once: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, INVITE_REQUEST_TIMEOUT_MS); + try { + if (signal?.aborted) abort(); + const response = await fetch( + `${base.replace(/\/+$/, "")}/api/join-policy`, + { signal: controller.signal }, + ); + // Relays predating join-policy support have no configured policy. + if (response.status === 404) return null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + raw = + ( + (await readBoundedJson(response, MAX_JOIN_POLICY_RESPONSE_BYTES)) as { + policy?: RawJoinPolicy; + } + ).policy ?? null; + } catch (error) { + if (timedOut) { + throw new Error("The community's join policy took too long to load."); + } + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } } return raw ? {