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]);
}

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

@@ -323,7 +323,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

@@ -304,6 +304,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,
@@ -157,9 +164,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 ||
@@ -204,6 +227,24 @@ 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 storage = ref.read(communityStorageProvider);
final nsec = await storage.pendingInviteIdentity(
invite.relayUrl,
@@ -217,8 +258,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
final keys = nostr.Keys(decodedKey.data);
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);
@@ -267,6 +307,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,
@@ -458,8 +499,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) {
@@ -475,8 +516,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

@@ -13,7 +13,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';
@@ -30,12 +32,14 @@ enum HostedInviteFailure {
Future<Map<String, dynamic>> readJoinResponse(
http.Client client,
http.Request request, {
bool allowNotFound = false,
int maxBytes = 65536,
}) async {
final http.Response response;
final Map<String, dynamic> data;
try {
response = await boundedHttpResponse(client, request, maxBytes: maxBytes);
if (allowNotFound && response.statusCode == 404) return {};
if (response.statusCode == 429 ||
(response.statusCode >= 500 && response.statusCode < 600)) {
throw AccountFailure.unavailable;
@@ -67,6 +71,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,
@@ -76,41 +81,30 @@ 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';
request.body = jsonEncode(body);
}
// The relay permits two 256 KiB Markdown documents. Allow their JSON
// escaping (up to six bytes per input byte), plus the policy envelope.
// Receipt and claim responses retain the ordinary 64 KiB bound.
final data = await readJoinResponse(
client,
request,
maxBytes: path == '/api/join-policy' ? 4 * 1024 * 1024 : 65536,
);
final data = await readJoinResponse(client, request);
checkCurrent();
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', {
@@ -124,3 +118,45 @@ 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;
// The relay permits two 256 KiB Markdown documents. Allow their JSON
// escaping (up to six bytes per input byte), plus the policy envelope.
// Receipt and claim responses retain the ordinary 64 KiB bound.
final data = await readJoinResponse(
client,
request,
allowNotFound: allowLegacyRelay,
maxBytes: 4 * 1024 * 1024,
);
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,8 +1,9 @@
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 '../../shared/widgets/onboarding_step_page.dart';
import 'hosted_join_policy.dart';
import '../theme/grid.dart';
import '../widgets/onboarding_step_page.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';
@@ -16,6 +18,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,
@@ -48,6 +89,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'),
@@ -119,6 +163,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
@@ -540,6 +587,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -576,6 +626,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -617,6 +670,9 @@ void main() {
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
pendingDeepLinkProvider.overrideWith(
@@ -677,6 +733,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
@@ -712,6 +771,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(
@@ -745,6 +807,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(link),
),
@@ -790,6 +855,9 @@ void main() {
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
@@ -864,6 +932,9 @@ void main() {
final recovery = _DeferredInviteJoinRecovery();
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(storage),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
@@ -917,6 +988,9 @@ void main() {
final pending = _QueuedPendingDeepLinkNotifier([first, second]);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
@@ -962,6 +1036,9 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
@@ -1003,6 +1080,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(
@@ -1034,6 +1114,9 @@ void main() {
final messageContainer = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(
const MessageDeepLink(
@@ -1075,6 +1158,9 @@ void main() {
);
final container = ProviderContainer(
overrides: [
inviteJoinHttpClientProvider.overrideWithValue(
MockClient((_) async => http.Response('{}', 200)),
),
pendingDeepLinkProvider.overrideWith(
() => _FakePendingDeepLinkNotifier(invite),
),
@@ -1170,7 +1256,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'}),
@@ -638,7 +712,7 @@ void main() {
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
attempts++;
expect(jsonDecode(request.body)['code'], 'same-code');
return attempts == 1
@@ -699,7 +773,7 @@ void main() {
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
_policyFreeClient((request) async {
attempts++;
bodies.add(request.body);
final event =
@@ -757,12 +831,7 @@ void main() {
InviteJoinStatus.success,
);
expect(attempts, 2);
expect(
bodies,
everyElement(
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
),
);
expect(bodies, everyElement(jsonEncode({'code': 'code'})));
expect((await storage.loadAll()).single.pubkey, claimedPubkey);
expect(
(await secure.readAll()).keys.where(
@@ -842,6 +911,67 @@ void main() {
},
);
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(),
),
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)
.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);
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();
final secondKeys = nostr.Keys.generate();
@@ -867,7 +997,7 @@ void main() {
});
}),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient(
_policyFreeClient(
(request) async => http.Response(
jsonEncode({
'status': 'joined',
@@ -976,3 +1106,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',
),
);
}
}