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 d676c7b8db
commit a1d1703b38
16 changed files with 850 additions and 137 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

@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { render, waitFor, cleanup, act } from "@testing-library/react";
import {
CommunityOnboardingProvider,
useCommunityOnboarding,
startCommunityOnboarding,
} 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;
}
});

View File

@@ -6,7 +6,7 @@ import {
isInviteExhaustedError,
isInviteExpiredError,
} from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites";
import { claimInvite, getJoinPolicy } from "@/shared/api/invites";
/**
* Drive the `claiming` stage after machine onboarding completes: claim the
@@ -21,21 +21,48 @@ 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: isInviteExpiredError(error)
@@ -45,8 +72,8 @@ export function useClaimInvite() {
: inviteErrorMessage(error),
},
transaction.id,
),
)
);
})
.finally(() => setIsPending(false));
}, [isPending, transaction, update]);
}

View File

@@ -413,3 +413,61 @@ test("persisted deep-link invite hands off to Joining after machine onboarding",
// The claim was attempted and its failure surfaced with a Retry.
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
test("receipt-less app handoff collects consent before claiming", async ({
page,
}) => {
const policy = {
version: "policy-v1",
terms_markdown: "Terms",
privacy_markdown: "Privacy",
age_attestation_required: true,
};
let claims = 0;
await page.route("**/api/join-policy", (route) =>
route.fulfill({ json: { policy } }),
);
await page.route("**/api/invites/accept-policy", (route) => {
expect(route.request().postDataJSON()).toEqual({
code: PENDING_JOIN_LINK.code,
policy_version: policy.version,
age_confirmed: true,
});
return route.fulfill({ json: { receipt: "native-consent" } });
});
await page.route("**/api/invites/claim", (route) => {
claims++;
expect(route.request().postDataJSON()).toEqual({
code: PENDING_JOIN_LINK.code,
policy_receipt: "native-consent",
});
return route.fulfill({
json: {
status: "joined",
community_id: "hive",
host: "hive.example.com",
role: "member",
},
});
});
await installMockBridge(page, {
pendingCommunityDeepLinks: [PENDING_JOIN_LINK],
});
await page.goto("/");
const age = page.getByRole("checkbox", {
name: "I am 18 years of age or older.",
});
await expect(age).toBeVisible();
const accept = page.getByRole("button", { name: "Accept and redeem invite" });
await expect(accept).toBeDisabled();
expect(claims).toBe(0);
await age.check();
await page
.getByRole("checkbox", {
name: /I agree to the Buzz Terms of Service and Privacy Policy/,
})
.check();
await accept.click();
await expect.poll(() => claims).toBe(1);
await expect(age).toHaveCount(0);
});

View File

@@ -321,7 +321,9 @@ private final class NativeCommunityEntryController: UIViewController, UITextFiel
UIAccessibility.post(notification: .announcement, argument: error)
}
self.navigationController?.sheetPresentationController?.invalidateDetents()
if result["joined"] as? Bool == true {
if result["continueInFlutter"] as? Bool == true {
self.finish?(false)
} else if result["joined"] as? Bool == true {
self.input.text = nil
self.finish?(true)
} else { completion?(result) }

View File

@@ -225,6 +225,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
final inviteState = ref.read(inviteJoinProvider);
final status = inviteState.status;
if (status == InviteJoinStatus.confirming ||
status == InviteJoinStatus.error ||
inviteState.isStarterSetupRecovery) {
final sheet = showInviteJoinSheet(navigatorContext);
if (status == InviteJoinStatus.claiming &&

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/hosted/hosted_relay_auth.dart';
import '../../shared/hosted/hosted_join_policy.dart';
import '../../shared/hosted/community_entry_requirements.dart';
import '../../shared/auth/auth.dart';
import '../../shared/deeplink/deep_link.dart';
@@ -75,6 +76,7 @@ class InviteJoinState {
final bool requiresFreshInvite;
final bool isStarterSetupRecovery;
final String? focusChannelId;
final HostedJoinPolicy? joinPolicy;
const InviteJoinState({
this.status = InviteJoinStatus.idle,
@@ -85,6 +87,7 @@ class InviteJoinState {
this.requiresFreshInvite = false,
this.isStarterSetupRecovery = false,
this.focusChannelId,
this.joinPolicy,
});
InviteJoinState copyWith({
@@ -96,7 +99,11 @@ class InviteJoinState {
bool? requiresFreshInvite,
bool? isStarterSetupRecovery,
Object? focusChannelId = _unset,
Object? joinPolicy = _unset,
}) => InviteJoinState(
joinPolicy: identical(joinPolicy, _unset)
? this.joinPolicy
: joinPolicy as HostedJoinPolicy?,
status: status ?? this.status,
invite: invite ?? this.invite,
host: host ?? this.host,
@@ -156,9 +163,25 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
host: _hostFromRelay(invite.relayUrl),
communityName: Community.nameFromUrl(invite.relayUrl),
);
try {
CommunityEntryRequirements.requireSelfHosted(invite.relayUrl);
final policy = await loadJoinPolicy(
client: ref.read(inviteJoinHttpClientProvider),
origin: Uri.parse(invite.relayUrl),
allowLegacyRelay: true,
);
if (!ref.mounted || state.invite != invite) return;
state = state.copyWith(joinPolicy: policy);
} catch (error) {
if (!ref.mounted || state.invite != invite) return;
state = state.copyWith(
status: InviteJoinStatus.error,
errorMessage: _friendlyInviteError(error),
);
}
}
Future<void> confirmJoin() async {
Future<void> confirmJoin({String? acceptedPolicyVersion}) async {
final invite = state.invite;
if (invite == null ||
state.requiresFreshInvite ||
@@ -202,11 +225,28 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
return;
}
final receipt = await freshJoinPolicyReceipt(
client: ref.read(inviteJoinHttpClientProvider),
origin: Uri.parse(invite.relayUrl),
code: invite.code,
allowLegacyRelay: true,
checkCurrent: () {
if (!ref.mounted || state.invite != invite) {
throw StateError('Invite changed');
}
},
confirm: (policy) async {
final accepted =
state.joinPolicy?.version == policy.version &&
acceptedPolicyVersion == policy.version;
state = state.copyWith(joinPolicy: policy);
return accepted;
},
);
final keys = ref.read(inviteKeyGeneratorProvider)();
final body = jsonEncode({
'code': invite.code,
if (invite.policyReceipt != null)
'policy_receipt': invite.policyReceipt,
'policy_receipt': ?receipt,
});
final relayUri = Uri.parse(invite.relayUrl);
validateInviteRelayUri(relayUri);
@@ -254,6 +294,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
state = state.copyWith(isStarterSetupRecovery: true);
await startStarterSetupRecovery();
} catch (error) {
if (!ref.mounted || state.invite != invite) return;
final requiresFreshInvite = _requiresFreshInvite(error);
state = state.copyWith(
status: InviteJoinStatus.error,
@@ -431,8 +472,8 @@ String _communityNameFromClaim(Map<String, dynamic> claim, String relayUrl) {
bool _requiresFreshInvite(Object error) {
final message = error.toString();
return message.contains('join_policy_required') ||
message.contains('invite_exhausted');
return message.contains('invite_exhausted') ||
error == HostedInviteFailure.exhausted;
}
String _friendlyInviteError(Object error) {
@@ -442,8 +483,9 @@ String _friendlyInviteError(Object error) {
return 'This invite has reached its use limit. Ask for a new invite.';
}
if (message.contains('invite_invalid')) return 'This invite is not valid.';
if (message.contains('join_policy_required')) {
return 'This invite approval has expired. Re-open the invite link to try again.';
if (message.contains('join_policy_required') ||
error == HostedInviteFailure.policyRequired) {
return 'Review and accept the current community policy to continue.';
}
if (message.contains('SocketException') ||
message.contains('Connection refused') ||

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../../shared/identity_transfer/native_community_entry.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
@@ -9,13 +11,16 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../pairing/pairing_page.dart';
import 'invite_join_provider.dart';
import '../../shared/hosted/hosted_join_policy_view.dart';
Future<bool?> showInviteJoinSheet(BuildContext context) async {
final container = ProviderScope.containerOf(context);
final state = container.read(inviteJoinProvider);
var continueWithPolicy = false;
if (defaultTargetPlatform == TargetPlatform.iOS &&
state.status == InviteJoinStatus.confirming &&
!state.isStarterSetupRecovery) {
!state.isStarterSetupRecovery &&
state.joinPolicy == null) {
final joined = await NativeCommunityEntry.show(
name: state.host ?? 'Unknown community',
dark: Theme.of(context).brightness == Brightness.dark,
@@ -31,12 +36,18 @@ Future<bool?> showInviteJoinSheet(BuildContext context) async {
}
await container.read(inviteJoinProvider.notifier).confirmJoin();
final result = container.read(inviteJoinProvider);
if (result.joinPolicy != null &&
result.status == InviteJoinStatus.error &&
!result.requiresFreshInvite) {
continueWithPolicy = true;
return {'continueInFlutter': true};
}
return result.status == InviteJoinStatus.success
? {'joined': true}
: {'error': result.errorMessage ?? 'Could not join. Try again.'};
},
);
if (joined == false) return false;
if (joined == false && !continueWithPolicy) return false;
if (!context.mounted) return joined;
// Preserve the existing success screen and starter-channel destination.
}
@@ -170,6 +181,19 @@ class InviteJoinSheet extends ConsumerWidget {
),
],
const SizedBox(height: Grid.lg),
if (state.joinPolicy != null &&
!state.requiresFreshInvite &&
!isStarterSetupRecovery &&
!isClaiming)
HostedJoinPolicyView(
key: ValueKey(state.joinPolicy!.version),
policy: state.joinPolicy!,
onAccept: () => ref
.read(inviteJoinProvider.notifier)
.confirmJoin(
acceptedPolicyVersion: state.joinPolicy!.version,
),
),
Row(
children: [
Expanded(
@@ -181,28 +205,31 @@ class InviteJoinSheet extends ConsumerWidget {
),
),
const SizedBox(width: Grid.sm),
Expanded(
child: FilledButton.icon(
onPressed: isClaiming || state.requiresFreshInvite
? null
: () async => ref
.read(inviteJoinProvider.notifier)
.confirmJoin(),
icon: isClaiming
? SizedBox(
width: 16,
height: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel: isStarterSetupRecovery
? 'Finishing setup'
: 'Joining community',
),
)
: const Icon(BuzzIcons.check),
label: Text(primaryLabel),
if (state.joinPolicy == null ||
isStarterSetupRecovery ||
isClaiming)
Expanded(
child: FilledButton.icon(
onPressed: isClaiming || state.requiresFreshInvite
? null
: () async => ref
.read(inviteJoinProvider.notifier)
.confirmJoin(),
icon: isClaiming
? SizedBox(
width: 16,
height: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel: isStarterSetupRecovery
? 'Finishing setup'
: 'Joining community',
),
)
: const Icon(BuzzIcons.check),
label: Text(primaryLabel),
),
),
),
],
),
],

View File

@@ -8,7 +8,7 @@ import '../../shared/utils/string_utils.dart';
import '../../shared/theme/grid.dart';
import '../../shared/theme/theme_extensions.dart';
import 'hosted_community_selection.dart';
import 'hosted_join_policy_view.dart';
import '../../shared/hosted/hosted_join_policy_view.dart';
import 'hosted_profile_setup.dart';
/// Presents the app-scoped real flow without retaining credentials or keys.

View File

@@ -1,5 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'builderlab_account.dart';
import 'bounded_http_response.dart';
@@ -22,12 +24,14 @@ enum HostedInviteFailure { policyRequired, expired, exhausted, invalid }
/// Reads only a bounded JSON document, never follows redirects or logs bodies.
Future<Map<String, dynamic>> readJoinResponse(
http.Client client,
http.Request request,
) async {
http.Request request, {
bool allowNotFound = false,
}) async {
final http.Response response;
final Map<String, dynamic> data;
try {
response = await boundedHttpResponse(client, request);
if (allowNotFound && response.statusCode == 404) return {};
if (response.statusCode == 429 ||
(response.statusCode >= 500 && response.statusCode < 600)) {
throw AccountFailure.unavailable;
@@ -57,6 +61,7 @@ Future<String?> freshJoinPolicyReceipt({
required String code,
required Future<bool> Function(HostedJoinPolicy) confirm,
required void Function() checkCurrent,
bool allowLegacyRelay = false,
}) async {
Future<Map<String, dynamic>> send(
String method,
@@ -66,7 +71,12 @@ Future<String?> freshJoinPolicyReceipt({
checkCurrent();
final request = http.Request(
method,
origin.replace(scheme: 'https', path: path),
origin.replace(
scheme: origin.scheme == 'ws' || origin.scheme == 'http'
? 'http'
: 'https',
path: path,
),
)..followRedirects = false;
if (body != null) {
request.headers['Content-Type'] = 'application/json';
@@ -77,23 +87,14 @@ Future<String?> freshJoinPolicyReceipt({
return data;
}
final data = await send('GET', '/api/join-policy');
final raw = data['policy'];
if (raw == null) return null;
if (raw is! Map ||
raw['version'] is! String ||
(raw['version'] as String).isEmpty ||
raw['age_attestation_required'] is! bool ||
(raw['terms_markdown'] != null && raw['terms_markdown'] is! String) ||
(raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) {
throw AccountFailure.invalidResponse;
}
final policy = HostedJoinPolicy(
version: raw['version'] as String,
ageRequired: raw['age_attestation_required'] as bool,
terms: raw['terms_markdown'] as String?,
privacy: raw['privacy_markdown'] as String?,
checkCurrent();
final policy = await loadJoinPolicy(
client: client,
origin: origin,
allowLegacyRelay: allowLegacyRelay,
);
checkCurrent();
if (policy == null) return null;
if (!await confirm(policy)) throw HostedInviteFailure.policyRequired;
checkCurrent();
final accepted = await send('POST', '/api/invites/accept-policy', {
@@ -107,3 +108,41 @@ Future<String?> freshJoinPolicyReceipt({
}
return receipt;
}
/// Fetch a policy without accepting it; legacy self-hosted relays may omit it.
Future<HostedJoinPolicy?> loadJoinPolicy({
required http.Client client,
required Uri origin,
bool allowLegacyRelay = false,
}) async {
final request = http.Request(
'GET',
origin.replace(
scheme: origin.scheme == 'ws' || origin.scheme == 'http'
? 'http'
: 'https',
path: '/api/join-policy',
),
)..followRedirects = false;
final data = await readJoinResponse(
client,
request,
allowNotFound: allowLegacyRelay,
);
final raw = data['policy'];
if (raw == null) return null;
if (raw is! Map ||
raw['version'] is! String ||
(raw['version'] as String).isEmpty ||
raw['age_attestation_required'] is! bool ||
(raw['terms_markdown'] != null && raw['terms_markdown'] is! String) ||
(raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) {
throw AccountFailure.invalidResponse;
}
return HostedJoinPolicy(
version: raw['version'] as String,
ageRequired: raw['age_attestation_required'] as bool,
terms: raw['terms_markdown'] as String?,
privacy: raw['privacy_markdown'] as String?,
);
}

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/hosted/hosted_join_policy.dart';
import '../../shared/theme/grid.dart';
import 'hosted_join_policy.dart';
import '../theme/grid.dart';
/// Consent is explicit and scoped to the policy version shown on this screen.
class HostedJoinPolicyView extends HookWidget {

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
@@ -15,6 +17,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/community/community_storage_test.dart';
void main() {
testWidgets('policy discovery failure opens the invite retry sheet', (
tester,
) async {
const link = InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 503)),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())),
),
),
);
await tester.pumpAndSettle();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
expect(
find.textContaining('Could not join this community'),
findsOneWidget,
);
expect(find.widgetWithText(FilledButton, 'Join'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
for (final outcome in ['handled', 'declined', 'failed']) {
testWidgets('hosted handler drains queued links after $outcome setup', (
tester,
@@ -47,6 +88,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(() => pending),
inviteKeyGeneratorProvider.overrideWithValue(
() => throw StateError('must not generate'),
@@ -117,6 +161,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(() => pending),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
@@ -154,6 +201,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -190,6 +240,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -231,6 +284,9 @@ void main() {
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
pendingDeepLinkProvider.overrideWith(
@@ -291,6 +347,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
@@ -331,6 +390,9 @@ void main() {
final pending = _RecordingPendingDeepLinkNotifier(link);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(() => pending),
channelsProvider.overrideWith(
@@ -364,6 +426,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -409,6 +474,9 @@ void main() {
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
@@ -483,6 +551,9 @@ void main() {
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
@@ -533,6 +604,9 @@ void main() {
final pending = _QueuedPendingDeepLinkNotifier([first, second]);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
@@ -578,6 +652,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
@@ -619,6 +696,9 @@ void main() {
final inviteStorage = CommunityStorage(secure: FakeSecureStorage());
final inviteContainer = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(inviteStorage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
@@ -650,6 +730,9 @@ void main() {
final messageContainer = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const MessageDeepLink(
@@ -691,6 +774,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(invite),
),
@@ -786,7 +872,7 @@ class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier {
}
@override
Future<void> confirmJoin() async {
Future<void> confirmJoin({String? acceptedPolicyVersion}) async {
state = state.copyWith(
status: InviteJoinStatus.success,
focusChannelId: 'welcome-everyone-id',

View File

@@ -18,6 +18,80 @@ import 'package:buzz/shared/deeplink/deep_link.dart';
import '../../shared/community/community_storage_test.dart';
void main() {
test(
'receipt-less invite requires current native consent before acceptance and claim',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final requests = <String>[];
var version = 'v1';
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
requests.add(request.url.path);
if (request.url.path == '/api/join-policy') {
return http.Response(
jsonEncode({
'policy': {
'version': version,
'terms_markdown': 'Terms',
'age_attestation_required': true,
},
}),
200,
);
}
if (request.url.path == '/api/invites/accept-policy') {
expect(jsonDecode(request.body), {
'code': 'code',
'policy_version': version,
'age_confirmed': true,
});
return http.Response('{"receipt":"fresh"}', 200);
}
expect(jsonDecode(request.body), {
'code': 'code',
'policy_receipt': 'fresh',
});
return http.Response(
'{"status":"joined","host":"relay.example.com","role":"member"}',
200,
);
}),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(inviteJoinProvider.notifier);
await notifier.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'untrusted-browser-receipt',
),
);
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1');
await notifier.confirmJoin();
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
version = 'v2';
await notifier.confirmJoin(acceptedPolicyVersion: 'v1');
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v2');
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
await notifier.confirmJoin(acceptedPolicyVersion: 'v2');
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
expect(requests.where((path) => path == '/api/invites/claim').length, 1);
},
);
test(
'legacy invite entry refuses staging before key generation or claim',
() async {
@@ -32,7 +106,7 @@ void main() {
return nostr.Keys.generate();
}),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((_) async {
_policyFreeClient((_) async {
requests++;
return http.Response('{}', 500);
}),
@@ -88,7 +162,7 @@ void main() {
return nostr.Keys.generate();
}),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
claimRequests++;
return http.Response('{}', 500);
}),
@@ -137,7 +211,7 @@ void main() {
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
capturedRequest = request;
return http.Response(
jsonEncode({
@@ -411,7 +485,7 @@ void main() {
_FakeInviteJoinRecovery(error: Exception('relay disconnected')),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
claimRequests++;
return http.Response(
jsonEncode({
@@ -455,7 +529,7 @@ void main() {
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
claimRequests++;
return http.Response('{}', 500);
}),
@@ -527,7 +601,7 @@ void main() {
},
);
test('join_policy_required requires a fresh link and cannot retry', () async {
test('join_policy_required can renew consent without a fresh link', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
@@ -536,7 +610,7 @@ void main() {
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
attempts++;
return http.Response(
jsonEncode({'error': 'join_policy_required'}),
@@ -561,14 +635,14 @@ void main() {
final state = container.read(inviteJoinProvider);
expect(state.status, InviteJoinStatus.error);
expect(state.requiresFreshInvite, isTrue);
expect(state.requiresFreshInvite, isFalse);
expect(
state.errorMessage,
'This invite approval has expired. Re-open the invite link to try again.',
'Review and accept the current community policy to continue.',
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(attempts, 1);
expect(attempts, 2);
});
test('invite_exhausted requires a fresh invite and cannot retry', () async {
@@ -580,7 +654,7 @@ void main() {
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
attempts++;
return http.Response(
jsonEncode({'error': 'invite_exhausted'}),
@@ -614,65 +688,66 @@ void main() {
expect(attempts, 1);
});
test('failed claim can be retried and preserves policy receipt', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final bodies = <String>[];
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
attempts++;
bodies.add(request.body);
if (attempts == 1) {
return http.Response(jsonEncode({'error': 'temporary'}), 503);
}
return http.Response(
jsonEncode({
'status': 'joined',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'receipt.value',
test(
'failed claim can be retried without reusing a browser receipt',
() async {
final keys = nostr.Keys.generate();
var attempts = 0;
final bodies = <String>[];
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
attempts++;
bodies.add(request.body);
if (attempts == 1) {
return http.Response(jsonEncode({'error': 'temporary'}), 503);
}
return http.Response(
jsonEncode({
'status': 'joined',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
addTearDown(container.dispose);
await container.read(inviteJoinProvider.notifier).confirmJoin();
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'receipt.value',
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success);
expect(attempts, 2);
expect(
bodies,
everyElement(
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
),
);
expect(auth.authenticatedCommunities, hasLength(1));
});
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
expect(attempts, 2);
expect(bodies, everyElement(jsonEncode({'code': 'code'})));
expect(auth.authenticatedCommunities, hasLength(1));
},
);
test('builds a fresh recovery with the second community identity', () async {
final firstKeys = nostr.Keys.generate();
@@ -699,7 +774,7 @@ void main() {
});
}),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient(
_policyFreeClient(
(request) async => http.Response(
jsonEncode({
'status': 'joined',
@@ -808,3 +883,10 @@ class _RecordingAuthNotifier extends AuthNotifier {
);
}
}
http.Client _policyFreeClient(
Future<http.Response> Function(http.Request) claim,
) => http_testing.MockClient((request) async {
if (request.url.path == '/api/join-policy') return http.Response('{}', 200);
return claim(request);
});

View File

@@ -1,11 +1,122 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:buzz/features/invites/invite_join_provider.dart';
import 'package:buzz/features/invites/invite_join_sheet.dart';
import 'package:flutter/material.dart';
import 'package:buzz/shared/hosted/hosted_join_policy.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets(
'iOS confirmation hands a newly required policy back to Flutter',
(tester) async {
const channel = MethodChannel('buzz/community_entry');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final dismissed = Completer<bool>();
Map<dynamic, dynamic>? presentation;
messenger.setMockMethodCallHandler(channel, (call) async {
presentation = call.arguments as Map;
return dismissed.future;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
await tester.pumpWidget(
WidgetHelpers.testable(
child: const _InviteJoinSheetLauncher(),
overrides: [
inviteJoinProvider.overrideWith(_NewPolicyInviteJoinNotifier.new),
],
),
);
await tester.tap(find.text('Open invite'));
await tester.pumpAndSettle();
expect(presentation, isNotNull);
final response = Completer<Map<dynamic, dynamic>>();
messenger.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall('join', {'session': presentation!['session']}),
),
(data) => response.complete(
const StandardMethodCodec().decodeEnvelope(data!) as Map,
),
);
await tester.pump();
expect(await response.future, {'continueInFlutter': true});
dismissed.complete(false);
await tester.pumpAndSettle();
expect(find.text('Accept and join'), findsOneWidget);
expect(find.text('Terms of service'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets('exhausted policy invite has no enabled acceptance action', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
child: const InviteJoinSheet(),
overrides: [
inviteJoinProvider.overrideWith(
() => _StaticInviteJoinNotifier(
const InviteJoinState(
status: InviteJoinStatus.error,
host: 'relay.example.com',
requiresFreshInvite: true,
errorMessage: 'Ask for a new invite.',
joinPolicy: HostedJoinPolicy(
version: 'v1',
ageRequired: false,
terms: 'Terms',
),
),
),
),
],
),
);
expect(find.text('Accept and join'), findsNothing);
expect(find.text('Ask for a new invite.'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets(
'policy consent requires age confirmation and exposes cancellation',
(tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
child: const InviteJoinSheet(),
overrides: [
inviteJoinProvider.overrideWith(
() => _StaticInviteJoinNotifier(
const InviteJoinState(
status: InviteJoinStatus.confirming,
host: 'relay.example.com',
joinPolicy: HostedJoinPolicy(
version: 'v1',
ageRequired: true,
terms: 'Terms',
),
),
),
),
],
),
);
final accept = find.widgetWithText(FilledButton, 'Accept and join');
expect(tester.widget<FilledButton>(accept).onPressed, isNull);
expect(find.text('Cancel'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Join'), findsNothing);
await tester.tap(find.byType(CheckboxListTile));
await tester.pump();
expect(tester.widget<FilledButton>(accept).onPressed, isNotNull);
},
);
testWidgets('recovery error is scrollable and exposes retry setup', (
tester,
) async {
@@ -117,3 +228,23 @@ class _RecoveryErrorInviteJoinNotifier extends InviteJoinNotifier {
isStarterSetupRecovery: true,
);
}
class _NewPolicyInviteJoinNotifier extends InviteJoinNotifier {
@override
InviteJoinState build() => const InviteJoinState(
status: InviteJoinStatus.confirming,
host: 'relay.example.com',
);
@override
Future<void> confirmJoin({String? acceptedPolicyVersion}) async {
state = state.copyWith(
status: InviteJoinStatus.error,
joinPolicy: const HostedJoinPolicy(
version: 'new',
ageRequired: false,
terms: 'Terms',
),
);
}
}