From 93140fa3a31555a7605d04ef01f7b2eb77c91b49 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 19:15:39 +1100 Subject: [PATCH] fix(desktop): ignore deep-link policy receipts for native invite consent Anyone holding an invite code can mint a join-policy receipt from the public accept-policy endpoint, so a receipt carried by a buzz://join link does not prove this app showed consent. Desktop persisted that receipt into the onboarding transaction (and kept a retained same-relay receipt on resume), which made useClaimInvite skip policy discovery and claim without the native consent stage. startCommunityOnboarding now drops receipts for deep-link-join transactions, both when creating one and when resuming a same-relay transaction, so the invite-consent stage is the only source of the receipt a link join claims with. The deep-link drain no longer forwards the link's receipt. The useClaimInvite jsdom table gains rows for a link-supplied receipt and a retained same-relay receipt. Each row asserts the flow reaches invite-consent with zero claims, then claims once with the app-minted receipt after native acceptance. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../onboarding/communityOnboarding.tsx | 12 ++- .../onboarding/useClaimInvite.jsdom-test.mjs | 82 +++++++++++++++---- desktop/src/shared/deep-link.ts | 7 +- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index ad21c33467..0184b2014d 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -155,6 +155,12 @@ export function startCommunityOnboarding( now = new Date(), ): CommunityOnboardingTransaction { const relayUrl = canonicalRelayUrl(input.relayUrl); + // Anyone holding the invite code can mint a receipt from the public + // accept-policy endpoint, so a receipt arriving with an app handoff link + // proves nothing about consent. Link joins start without one — and drop any + // retained same-relay receipt — so only the native `invite-consent` stage + // can supply the receipt they claim with. + const trustsReceipt = input.source !== "deep-link-join"; const existing = loadCommunityOnboardingTransaction(storage); if (existing?.relayUrl === relayUrl) { const updated = { @@ -165,7 +171,9 @@ export function startCommunityOnboarding( communityName: input.communityName?.trim() || existing.communityName, token: input.token?.trim() || existing.token, reposDir: input.reposDir ?? existing.reposDir, - policyReceipt: input.policyReceipt ?? existing.policyReceipt, + policyReceipt: trustsReceipt + ? (input.policyReceipt ?? existing.policyReceipt) + : undefined, updatedAt: now.toISOString(), error: undefined, // A freshly opened link deserves fresh feedback — re-present the gate @@ -187,7 +195,7 @@ export function startCommunityOnboarding( communityName: input.communityName?.trim() || deriveCommunityName(relayUrl), token: input.token?.trim() || undefined, reposDir: input.reposDir, - policyReceipt: input.policyReceipt, + policyReceipt: trustsReceipt ? input.policyReceipt : undefined, createdAt: timestamp, updatedAt: timestamp, }; diff --git a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs index c2a5133acc..a7420ed756 100644 --- a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs +++ b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs @@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client"; import { CommunityOnboardingProvider, loadCommunityOnboardingTransaction, + saveCommunityOnboardingTransaction, startCommunityOnboarding, useCommunityOnboarding, } from "./communityOnboarding.tsx"; @@ -33,15 +34,61 @@ const tauri = { window.__TAURI_INTERNALS__ = tauri; globalThis.__TAURI_INTERNALS__ = tauri; -for (const configured of [true, false]) { - test(`receipt-less invite ${configured ? "waits for native consent" : "claims a policy-free relay"}`, async () => { +const linkJoin = { + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "code", +}; +// Anyone holding the invite code can mint a receipt, so one arriving with the +// link (or retained from an earlier same-relay transaction) must not stand in +// for the app's own consent stage. +for (const [name, configured, seed] of [ + [ + "receipt-less invite claims a policy-free relay", + false, + () => startCommunityOnboarding(linkJoin), + ], + [ + "receipt-less invite waits for native consent", + true, + () => startCommunityOnboarding(linkJoin), + ], + [ + "link-supplied receipt cannot bypass native consent", + true, + () => + startCommunityOnboarding({ ...linkJoin, policyReceipt: "link-receipt" }), + ], + [ + "retained same-relay receipt cannot bypass native consent", + true, + () => { + const timestamp = new Date().toISOString(); + saveCommunityOnboardingTransaction({ + id: "retained", + source: "deep-link-join", + stage: "claiming", + relayUrl: "wss://relay.example", + inviteCode: "code", + communityName: "relay", + policyReceipt: "link-receipt", + createdAt: timestamp, + updatedAt: timestamp, + error: "offline", + }); + startCommunityOnboarding(linkJoin); + }, + ], +]) { + test(name, async () => { localStorage.clear(); - const requests = []; + const claimReceipts = []; const original = globalThis.fetch; - globalThis.fetch = async (url) => { - requests.push(String(url)); + globalThis.fetch = async (url, options) => { if (String(url).endsWith("/api/join-policy")) return Response.json(configured ? { policy } : {}); + assert.equal(url, "https://relay.example/api/invites/claim"); + claimReceipts.push(JSON.parse(options.body).policy_receipt); return Response.json({ status: "joined", community_id: "id", @@ -50,11 +97,7 @@ for (const configured of [true, false]) { }); }; try { - startCommunityOnboarding({ - source: "deep-link-join", - relayUrl: "wss://relay.example", - inviteCode: "code", - }); + seed(); render( React.createElement( CommunityOnboardingProvider, @@ -68,10 +111,18 @@ for (const configured of [true, false]) { configured ? "invite-consent" : "connecting", ), ); - assert.equal( - requests.filter((url) => url.endsWith("/claim")).length, - configured ? 0 : 1, - ); + if (configured) { + assert.equal(controls.transaction.policyReceipt, undefined); + assert.deepEqual(claimReceipts, []); + // Native acceptance, as the invite-consent stage's onRedeem applies it. + await act(async () => + controls.update({ policyReceipt: "app-receipt", stage: "claiming" }), + ); + await waitFor(() => + assert.equal(controls.transaction.stage, "connecting"), + ); + } + assert.deepEqual(claimReceipts, [configured ? "app-receipt" : undefined]); } finally { cleanup(); globalThis.fetch = original; @@ -85,8 +136,9 @@ test("rejected receipt returns to native consent and clears the stale proof", as globalThis.fetch = async () => Response.json({ error: "join_policy_required" }, { status: 403 }); try { + // An in-app form minted this receipt; link receipts never reach a claim. startCommunityOnboarding({ - source: "deep-link-join", + source: "add-community", relayUrl: "wss://relay.example", inviteCode: "code", policyReceipt: "expired", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 6f067c4e9f..520274630e 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -51,12 +51,13 @@ export type NostrBindDeepLinkPayload = { /** * Payload emitted by the Rust deep-link handler for `buzz://join?…` — - * a relay invite from the web landing page (`/invite/`). + * a relay invite from the web landing page (`/invite/`). Any + * `policy_receipt` the link carries is ignored: the app collects consent + * itself (see `startCommunityOnboarding`). */ export type JoinDeepLinkPayload = { relayUrl: string; code: string; - policyReceipt: string | null; }; type PendingCommunityDeepLink = { @@ -65,7 +66,6 @@ type PendingCommunityDeepLink = { relayUrl: string; code: string | null; name: string | null; - policyReceipt: string | null; }; type PendingEntityDeepLink = { @@ -89,7 +89,6 @@ function acceptPendingCommunityDeepLink( pending.kind === "join" ? "deep-link-join" : "deep-link-connect", relayUrl: pending.relayUrl, inviteCode: pending.code ?? undefined, - policyReceipt: pending.policyReceipt ?? undefined, }); return accepted ? invoke("acknowledge_pending_community_deep_link", {