fix(invites): collect native consent for app invite handoff

Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-10-26 21:35:57 +11:00
parent f6f6d9a673
commit 7a35f29d49
16 changed files with 859 additions and 98 deletions

View File

@@ -15,6 +15,7 @@ export type CommunityOnboardingSource =
| "deep-link-join";
export type CommunityOnboardingStage =
| "invite-consent"
| "claiming"
| "connecting"
| "profile"
@@ -61,6 +62,7 @@ export type CommunityOnboardingTransactionPatch = Partial<
Pick<
CommunityOnboardingTransaction,
| "stage"
| "policyReceipt"
| "relayUrl"
| "communityId"
| "previousCommunityId"
@@ -106,6 +108,7 @@ function isTransaction(
typeof transaction.createdAt === "string" &&
typeof transaction.updatedAt === "string" &&
[
"invite-consent",
"claiming",
"connecting",
"profile",

View File

@@ -30,6 +30,7 @@ import { cn } from "@/shared/lib/cn";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
import { InviteRedeemForm } from "./InviteRedeemForm";
import { MembershipDenied } from "./MembershipDenied";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingCard, useOnboardingCardLayout } from "./OnboardingCard";
@@ -525,8 +526,28 @@ export function CommunityOnboardingFlow({
)}
data-testid="community-onboarding-body"
>
{transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
{transaction.stage === "invite-consent" ? (
<>
<h1 className="mb-6 text-title font-normal">
Join {transaction.communityName}
</h1>
<InviteRedeemForm
key={`${transaction.id}:${transaction.inviteCode}`}
initialValue={`buzz://join?${new URLSearchParams({ relay: transaction.relayUrl, code: transaction.inviteCode ?? "" })}`}
readOnlyInvite
error={null}
isRedeeming={false}
onCancel={onCancel}
onRedeem={(_relayUrl, _inviteCode, policyReceipt) =>
update(
{ policyReceipt, stage: "claiming", error: undefined },
transaction.id,
)
}
/>
</>
) : transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
<>
<Users className="mx-auto h-10 w-10" />
<h1 className="mt-5 text-title font-normal">

View File

@@ -52,6 +52,7 @@ type InviteRedeemFormProps = {
defaultRelayUrl?: string;
error: string | null;
initialValue?: string;
readOnlyInvite?: boolean;
isRedeeming: boolean;
onCancel: () => void;
onConnect?: (relayWsUrl: string) => void;
@@ -64,6 +65,7 @@ export function InviteRedeemForm({
defaultRelayUrl,
error,
initialValue = "",
readOnlyInvite = false,
isRedeeming,
onCancel,
onConnect,
@@ -405,7 +407,8 @@ export function InviteRedeemForm({
<Input
autoComplete="off"
autoCorrect="off"
autoFocus
autoFocus={!readOnlyInvite}
readOnly={readOnlyInvite}
className={
isAddCommunity
? "h-11 rounded-xl border-input bg-muted/40 px-3 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-visible:border-muted-foreground/50 focus-visible:ring-0"

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import React from "react";
import { createRoot } from "react-dom/client";
import {
@@ -11,6 +12,186 @@ import {
} from "./communityOnboarding.tsx";
import { useClaimInvite } from "./useClaimInvite.ts";
let controls;
function Claim() {
controls = useCommunityOnboarding();
useClaimInvite();
return null;
}
const policy = {
version: "v1",
terms_markdown: "Terms",
age_attestation_required: false,
};
const tauri = {
invoke: async (command) => {
if (command === "sign_event")
return JSON.stringify({ id: "signed", tags: [] });
throw new Error(`Unexpected command ${command}`);
},
};
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 () => {
localStorage.clear();
const requests = [];
const original = globalThis.fetch;
globalThis.fetch = async (url) => {
requests.push(String(url));
if (String(url).endsWith("/api/join-policy"))
return Response.json(configured ? { policy } : {});
return Response.json({
status: "joined",
community_id: "id",
host: "relay.example",
role: "member",
});
};
try {
startCommunityOnboarding({
source: "deep-link-join",
relayUrl: "wss://relay.example",
inviteCode: "code",
});
render(
React.createElement(
CommunityOnboardingProvider,
null,
React.createElement(Claim),
),
);
await waitFor(() =>
assert.equal(
controls.transaction.stage,
configured ? "invite-consent" : "connecting",
),
);
assert.equal(
requests.filter((url) => url.endsWith("/claim")).length,
configured ? 0 : 1,
);
} finally {
cleanup();
globalThis.fetch = original;
}
});
}
test("rejected receipt returns to native consent and clears the stale proof", async () => {
localStorage.clear();
const original = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({ error: "join_policy_required" }, { status: 403 });
try {
startCommunityOnboarding({
source: "deep-link-join",
relayUrl: "wss://relay.example",
inviteCode: "code",
policyReceipt: "expired",
});
render(
React.createElement(
CommunityOnboardingProvider,
null,
React.createElement(Claim),
),
);
await waitFor(() =>
assert.equal(controls.transaction.stage, "invite-consent"),
);
assert.equal(controls.transaction.policyReceipt, undefined);
assert.equal(controls.transaction.error, undefined);
} finally {
cleanup();
globalThis.fetch = original;
}
});
test("policy lookup failure never claims and can be retried", async () => {
localStorage.clear();
const original = globalThis.fetch;
let failed = true;
let claims = 0;
globalThis.fetch = async (url) => {
if (String(url).endsWith("/api/join-policy")) {
if (failed) throw new Error("offline");
return Response.json({ policy });
}
claims++;
throw new Error("unexpected claim");
};
try {
startCommunityOnboarding({
source: "deep-link-join",
relayUrl: "wss://relay.example",
inviteCode: "code",
});
render(
React.createElement(
CommunityOnboardingProvider,
null,
React.createElement(Claim),
),
);
await waitFor(() => assert.equal(controls.transaction.error, "offline"));
failed = false;
await act(async () => controls.update({ error: undefined }));
await waitFor(() =>
assert.equal(controls.transaction.stage, "invite-consent"),
);
assert.equal(claims, 0);
} finally {
cleanup();
globalThis.fetch = original;
}
});
test("cancelled policy discovery cannot claim after its response arrives", async () => {
localStorage.clear();
const original = globalThis.fetch;
let resolvePolicy;
let started;
const lookupStarted = new Promise((resolve) => {
started = resolve;
});
const response = new Promise((resolve) => {
resolvePolicy = resolve;
});
let claims = 0;
globalThis.fetch = async (url) => {
if (String(url).endsWith("/api/join-policy")) {
started();
return response;
}
claims++;
throw new Error("unexpected claim");
};
try {
startCommunityOnboarding({
source: "deep-link-join",
relayUrl: "wss://relay.example",
inviteCode: "code",
});
render(
React.createElement(
CommunityOnboardingProvider,
null,
React.createElement(Claim),
),
);
await lookupStarted;
await act(async () => controls.clear());
await act(async () => resolvePolicy(Response.json({})));
assert.equal(controls.transaction, null);
assert.equal(claims, 0);
} finally {
cleanup();
globalThis.fetch = original;
}
});
for (const [sentinel, guidance] of [
[
"invite_restricted",
@@ -46,6 +227,8 @@ for (const [sentinel, guidance] of [
retryObserved = resolve;
});
globalThis.fetch = async (url, options) => {
// Receipt-less claims first discover the policy; this relay has none.
if (String(url).endsWith("/api/join-policy")) return Response.json({});
assert.equal(url, "https://relay.example/api/invites/claim");
assert.equal(JSON.parse(options.body).code, "same-code");
attempts++;

View File

@@ -1,8 +1,11 @@
import * as React from "react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { inviteClaimErrorMessage } from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites";
import {
inviteClaimErrorMessage,
inviteErrorMessage,
} from "@/shared/api/inviteHelpers";
import { claimInvite, getJoinPolicy } from "@/shared/api/invites";
/**
* Drive the `claiming` stage after machine onboarding completes: claim the
@@ -17,28 +20,55 @@ import { claimInvite } from "@/shared/api/invites";
export function useClaimInvite() {
const { transaction, update } = useCommunityOnboarding();
const [isPending, setIsPending] = React.useState(false);
const currentTransaction = React.useRef(transaction);
React.useEffect(() => {
currentTransaction.current = transaction;
return () => {
currentTransaction.current = null;
};
}, [transaction]);
React.useEffect(() => {
if (transaction?.stage !== "claiming" || transaction.error || isPending) {
return;
}
setIsPending(true);
void claimInvite(
transaction.relayUrl,
transaction.inviteCode ?? "",
transaction.policyReceipt,
)
.then(() => {
update({ stage: "connecting", error: undefined }, transaction.id);
})
.catch((error: unknown) =>
void (async () => {
const policy = !transaction.policyReceipt
? await getJoinPolicy(transaction.relayUrl, "webview")
: null;
if (currentTransaction.current !== transaction) return;
if (policy) {
update({ stage: "invite-consent" }, transaction.id);
return;
}
await claimInvite(
transaction.relayUrl,
transaction.inviteCode ?? "",
transaction.policyReceipt,
);
update({ stage: "connecting", error: undefined }, transaction.id);
})()
.catch((error: unknown) => {
if (currentTransaction.current !== transaction) return;
if (inviteErrorMessage(error).includes("join_policy_required")) {
update(
{
stage: "invite-consent",
policyReceipt: undefined,
error: undefined,
},
transaction.id,
);
return;
}
update(
{
error: inviteClaimErrorMessage(error),
},
transaction.id,
),
)
);
})
.finally(() => setIsPending(false));
}, [isPending, transaction, update]);
}