diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx
index 99da68aa95..ad21c33467 100644
--- a/desktop/src/features/onboarding/communityOnboarding.tsx
+++ b/desktop/src/features/onboarding/communityOnboarding.tsx
@@ -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",
diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
index cec2cd1e2a..b742edf804 100644
--- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
@@ -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" ? (
+ <>
+
+ Join {transaction.communityName}
+
+
+ update(
+ { policyReceipt, stage: "claiming", error: undefined },
+ transaction.id,
+ )
+ }
+ />
+ >
+ ) : transaction.stage === "claiming" ||
+ transaction.stage === "connecting" ? (
<>
diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
index 52b9ebbd07..da5705341c 100644
--- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
+++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx
@@ -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({
{
+ 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;
+ }
+});
diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts
index 3134953617..c60b933fbe 100644
--- a/desktop/src/features/onboarding/useClaimInvite.ts
+++ b/desktop/src/features/onboarding/useClaimInvite.ts
@@ -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]);
}
diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts
index 42c4f8ea9f..173ad8497e 100644
--- a/desktop/tests/e2e/deep-link-invite.spec.ts
+++ b/desktop/tests/e2e/deep-link-invite.spec.ts
@@ -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);
+});
diff --git a/mobile/ios/Runner/NativeCommunityEntry.swift b/mobile/ios/Runner/NativeCommunityEntry.swift
index 9338c4041a..6f1448256d 100644
--- a/mobile/ios/Runner/NativeCommunityEntry.swift
+++ b/mobile/ios/Runner/NativeCommunityEntry.swift
@@ -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) }
diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
index 177163ebbd..4416c91fde 100644
--- a/mobile/lib/features/channels/deep_link_dispatcher.dart
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -225,6 +225,7 @@ class _DeepLinkDispatcherState extends ConsumerState {
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 &&
diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart
index b7a6a7731d..3ddcaac83f 100644
--- a/mobile/lib/features/invites/invite_join_provider.dart
+++ b/mobile/lib/features/invites/invite_join_provider.dart
@@ -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 {
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 confirmJoin() async {
+ Future confirmJoin({String? acceptedPolicyVersion}) async {
final invite = state.invite;
if (invite == null ||
state.requiresFreshInvite ||
@@ -202,11 +225,28 @@ class InviteJoinNotifier extends Notifier {
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 {
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 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') ||
diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart
index 8039ad82c7..b3882bb03f 100644
--- a/mobile/lib/features/invites/invite_join_sheet.dart
+++ b/mobile/lib/features/invites/invite_join_sheet.dart
@@ -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 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 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),
+ ),
),
- ),
],
),
],
diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart
index 9e0f1bb965..843f85447b 100644
--- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart
+++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart
@@ -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.
diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart
index d91ac8dfde..278d5e3aa1 100644
--- a/mobile/lib/shared/hosted/hosted_join_policy.dart
+++ b/mobile/lib/shared/hosted/hosted_join_policy.dart
@@ -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