mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user