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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-10-29 19:15:39 +11:00
parent 7a35f29d49
commit 93140fa3a3
3 changed files with 80 additions and 21 deletions

View File

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

View File

@@ -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",

View File

@@ -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/<code>`).
* a relay invite from the web landing page (`/invite/<code>`). 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<boolean>("acknowledge_pending_community_deep_link", {