fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)

On main, "or sign in with email" on iOS fails immediately with "We could
not finish connecting. You can try again." No Builderlab page opens.

[#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser
bridge so the public account hosts (`app.builderlab.xyz`,
`app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side
still opens `/api/goose/v1/auth/login` for them, so the bridge rejects
every public sign-in before presenting a browser. Every iOS build since
#8700 is affected; organization sign-in on tenant hosts is not.

The matching Dart change was Wes's
[#8712](https://github.com/block/buzz/pull/8712). It was merged into the
#8700 branch, but main's squash of #8700 kept only the bridge half. This
PR restores #8712 on top of main:

- The public account opens `/auth/native` (state, S256 challenge,
callback and retained invite unchanged, without the CLI-only
`type`/`product` parameters). Organization tenants keep
`/api/goose/v1/auth/login`.
- A saved session whose account still needs web setup goes through the
same browser handoff, keeping its subject and destination.

Android has no path allowlist in its browser plugin, so it needs no
change.

### Validation

- Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based
debug build: "or sign in with email" fails instantly. With this change
applied, the same build shows the system "Buzz wants to use
app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz
"Welcome back to Buzz" page, which confirms production serves
`/auth/native`.
- Restored #8712's tests on top of main's current tests: the browser
test checks the public login URL and its exact parameters, and the
hosted invite journey runs for both a fresh account and a saved account
that still needs setup, asserting `/auth/native` and that the invite is
retained. Without the Dart fix, these fail.
- Mobile format and analyze pass; the hosted onboarding journeys and
`shared/hosted` tests pass with the CI auth-domain configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-02 10:00:08 -08:00
committed by GitHub
parent f99807a79a
commit da93ce3f62
6 changed files with 224 additions and 148 deletions

View File

@@ -142,11 +142,13 @@ class BrowserAuth {
} catch (_) {
throw BrowserAuthFailure.storage;
}
// Public accounts must finish web setup before native code issuance.
// Organization accounts retain their existing direct CLI login.
final publicAccount = origin == HostedEnvironment.origin;
return origin.replace(
path: '/api/goose/v1/auth/login',
path: publicAccount ? '/auth/native' : '/api/goose/v1/auth/login',
queryParameters: {
'type': 'cli',
'product': 'buzz',
if (!publicAccount) ...{'type': 'cli', 'product': 'buzz'},
'returnTo': callback,
'state': state,
'code_challenge': base64UrlEncode(

View File

@@ -237,12 +237,8 @@ class BuilderlabAccountClient {
throw AccountFailure.invalidResponse;
}
if (!parsed.isAfter(DateTime.now().toUtc())) throw AccountFailure.signedOut;
final standing = result['account_standing'];
final needsSetup =
standing is Map &&
(standing['details_status'] == 'ACCOUNT_DETAILS_STATUS_MISSING' ||
standing['screening_status'] ==
'ACCOUNT_SCREENING_STATUS_NOT_SCREENED');
result['account_status'] == 'ACCOUNT_STATUS_NEEDS_DETAILS';
return BuilderlabAccount(
subject: subject,
expiresAt: parsed,

View File

@@ -533,6 +533,15 @@ class HostedFlow extends ChangeNotifier {
if ((target == null && !accountOnly) || session == null) {
throw AccountFailure.signedOut;
}
// Older native sessions may predate the web setup gate. Preserve their
// subject and destination while completing the same browser handoff.
if (session.needsAccountSetup) {
_set(HostedFlowStage.signIn);
await browser.start(
target == null ? {'type': 'account'} : _intent(target),
);
return;
}
final store = this.store.forSubject(session.subject);
if (target != null && !_acceptTarget(target)) return;
if (target == null && _batchSubject != session.subject) {

View File

@@ -847,143 +847,160 @@ void main() {
requiresBuilderlab: false,
);
journey(
'fresh account signs in, backs up, accepts invite and retries starters before home',
(tester, f) async {
await f.initialize(
localIdentity: false,
cloudChoice: true,
signedIn: false,
);
f.starterFailures = 2;
final browserReturn = Completer<void>();
f.beforeBrowserReturn = () => browserReturn.future;
final enrollmentReached = Completer<void>();
final enrollmentReturn = Completer<void>();
f.beforeEnrollmentReturn = () {
enrollmentReached.complete();
return enrollmentReturn.future;
};
await f.mount(tester);
await f.link(tester, 'one');
try {
for (final savedIncomplete in [false, true]) {
journey(
'${savedIncomplete ? 'incomplete saved' : 'fresh'} account signs in, backs up, accepts invite and retries starters before home',
(tester, f) async {
await f.initialize(
localIdentity: false,
cloudChoice: true,
signedIn: savedIncomplete,
);
f.needsAccountSetup = savedIncomplete;
f.starterFailures = 2;
final browserReturn = Completer<void>();
f.beforeBrowserReturn = () async {
await browserReturn.future;
f.needsAccountSetup = false;
};
final enrollmentReached = Completer<void>();
final enrollmentReturn = Completer<void>();
f.beforeEnrollmentReturn = () {
enrollmentReached.complete();
return enrollmentReturn.future;
};
await f.mount(tester);
await f.link(tester, 'one');
try {
expect(
find.text('Enter a community link').hitTestable(),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(f.browserUrls.single.path, '/auth/native');
expect(f.browserUrls.single.origin, HostedEnvironment.origin.origin);
expect(
await f.flow.browser.retainedIntent(),
containsPair('code', 'invite-one'),
);
expect(f.sent('/exchange'), savedIncomplete ? hasLength(1) : isEmpty);
expect(f.sent('/api/invites/claim'), isEmpty);
expect(find.text('Start login again'), findsNothing);
expect(find.text('Complete sign-in in your browser.'), findsNothing);
} finally {
browserReturn.complete();
}
await f.settle(tester);
await tester.tap(find.text('Create my Buzz identity'));
await enrollmentReached.future.timeout(const Duration(seconds: 5));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
try {
expect(find.byTooltip('Back').hitTestable(), findsNothing);
// External teardown still fences pending enrollment.
await f.flow.leave();
await tester.pump();
} finally {
enrollmentReturn.complete();
}
await f.settle(tester);
expect(f.sent('/nsec-backups'), isEmpty);
final retainedKey = (await f.store.read())!.pubkey;
f.beforeEnrollmentReturn = null;
// A dispatched upload can succeed after cancellation. Its receipt must
// still be durable; resuming must not replay an acknowledged backup.
f.beforeUploadReturn = () => f.flow.leave();
await f.link(tester, 'one');
expect((await f.store.read())!.backup, isNotNull);
f.beforeUploadReturn = null;
// Production legal documents exceed the ordinary 64 KiB account limit.
f.policyResponses.add(
http.Response(
jsonEncode({
'policy': {
'version': 'one.communities.buzz.xyz',
'age_attestation_required': true,
'terms_markdown': 'Be kind to this community.',
'privacy_markdown': 'Public privacy notice. ' * 5000,
},
}),
200,
),
);
await f.link(tester, 'one');
expect((await f.store.read())!.pubkey, retainedKey);
expect(f.browserStarts, 1);
final generated = (await f.store.read())!;
expect(generated.pubkey, f.bound);
expect(generated.backup, isNotNull);
expect(f.sent('/verify'), hasLength(1));
expect(f.sent('/nsec-backups'), hasLength(1));
final accept = find.widgetWithText(FilledButton, 'Accept and join');
expect(tester.widget<FilledButton>(accept).onPressed, isNull);
expect(f.sent('/claim'), isEmpty);
expect(await f.storage.loadAll(), isEmpty);
await f.tap(tester, 'Terms of service');
expect(find.text('Be kind to this community.'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await f.settle(tester);
await f.tap(tester, 'Accept and join');
expect(find.text('Try again').hitTestable(), findsOneWidget);
final pending = (await f.storage.loadAll()).single;
expect(pending.starterSetupIncomplete, isTrue);
expect(pending.pubkey, generated.pubkey);
expect(f.sent('/claim'), hasLength(1));
expect(
find.text('Enter a community link').hitTestable(),
jsonDecode(f.sent('/claim').single.body)['policy_receipt'],
'synthetic-policy-receipt',
);
expect(
jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'],
isTrue,
);
// Reopen the saved invite after its claim was consumed. Starter recovery
// must retain the durable marker and must not claim it again.
await f.link(tester, 'one');
expect(
(await f.storage.loadAll()).single.starterSetupIncomplete,
isTrue,
);
expect(find.text('Try again').hitTestable(), findsOneWidget);
await f.tap(tester, 'Try again');
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Start login again'), findsNothing);
expect(find.text('Complete sign-in in your browser.'), findsNothing);
} finally {
browserReturn.complete();
}
await f.settle(tester);
await tester.tap(find.text('Create my Buzz identity'));
await enrollmentReached.future.timeout(const Duration(seconds: 5));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
try {
expect(find.byTooltip('Back').hitTestable(), findsNothing);
// External teardown still fences pending enrollment.
await f.flow.leave();
await tester.pump();
} finally {
enrollmentReturn.complete();
}
await f.settle(tester);
expect(f.sent('/nsec-backups'), isEmpty);
final retainedKey = (await f.store.read())!.pubkey;
f.beforeEnrollmentReturn = null;
// A dispatched upload can succeed after cancellation. Its receipt must
// still be durable; resuming must not replay an acknowledged backup.
f.beforeUploadReturn = () => f.flow.leave();
await f.link(tester, 'one');
expect((await f.store.read())!.backup, isNotNull);
f.beforeUploadReturn = null;
// Production legal documents exceed the ordinary 64 KiB account limit.
f.policyResponses.add(
http.Response(
jsonEncode({
'policy': {
'version': 'one.communities.buzz.xyz',
'age_attestation_required': true,
'terms_markdown': 'Be kind to this community.',
'privacy_markdown': 'Public privacy notice. ' * 5000,
},
}),
200,
),
);
await f.link(tester, 'one');
expect((await f.store.read())!.pubkey, retainedKey);
expect(f.browserStarts, 1);
final generated = (await f.store.read())!;
expect(generated.pubkey, f.bound);
expect(generated.backup, isNotNull);
expect(f.sent('/verify'), hasLength(1));
expect(f.sent('/nsec-backups'), hasLength(1));
final accept = find.widgetWithText(FilledButton, 'Accept and join');
expect(tester.widget<FilledButton>(accept).onPressed, isNull);
expect(f.sent('/claim'), isEmpty);
expect(await f.storage.loadAll(), isEmpty);
await f.tap(tester, 'Terms of service');
expect(find.text('Be kind to this community.'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await f.settle(tester);
await f.tap(tester, 'Accept and join');
expect(find.text('Try again').hitTestable(), findsOneWidget);
final pending = (await f.storage.loadAll()).single;
expect(pending.starterSetupIncomplete, isTrue);
expect(pending.pubkey, generated.pubkey);
expect(f.sent('/claim'), hasLength(1));
expect(
jsonDecode(f.sent('/claim').single.body)['policy_receipt'],
'synthetic-policy-receipt',
);
expect(
jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'],
isTrue,
);
// Reopen the saved invite after its claim was consumed. Starter recovery
// must retain the durable marker and must not claim it again.
await f.link(tester, 'one');
expect((await f.storage.loadAll()).single.starterSetupIncomplete, isTrue);
expect(find.text('Try again').hitTestable(), findsOneWidget);
await f.tap(tester, 'Try again');
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsOneWidget,
);
expect(
find.ancestor(
of: find.byType(TextField).hitTestable(),
matching: find.byType(BottomSheet),
),
findsNothing,
);
expect(f.sent('/claim'), hasLength(1));
expect(f.recoveryScopes, hasLength(3));
expect(
f.recoveryScopes.every((scope) => scope.nsec == pending.nsec),
isTrue,
);
await tester.enterText(
find.byType(TextField).hitTestable(),
'Journey Person',
);
await f.tap(tester, 'Continue');
expect(f.profileWrites, isEmpty);
await f.tap(tester, 'Skip');
expect(f.profileWrites, ['Journey Person']);
final saved = (await f.storage.loadAll()).single;
expect(saved.id, pending.id);
expect(saved.starterSetupIncomplete, isFalse);
expect(await f.storage.loadActiveId(), saved.id);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Community home').hitTestable(), findsOneWidget);
},
requiresAuthDomain: true,
);
expect(
find.ancestor(
of: find.byType(TextField).hitTestable(),
matching: find.byType(BottomSheet),
),
findsNothing,
);
expect(f.sent('/claim'), hasLength(1));
expect(f.recoveryScopes, hasLength(3));
expect(
f.recoveryScopes.every((scope) => scope.nsec == pending.nsec),
isTrue,
);
await tester.enterText(
find.byType(TextField).hitTestable(),
'Journey Person',
);
await f.tap(tester, 'Continue');
expect(f.profileWrites, isEmpty);
await f.tap(tester, 'Skip');
expect(f.profileWrites, ['Journey Person']);
final saved = (await f.storage.loadAll()).single;
expect(saved.id, pending.id);
expect(saved.starterSetupIncomplete, isFalse);
expect(await f.storage.loadActiveId(), saved.id);
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Community home').hitTestable(), findsOneWidget);
},
requiresAuthDomain: true,
);
}
for (final boundAccount in [false, true]) {
journey(
@@ -1283,16 +1300,26 @@ void main() {
);
journey(
'signed-in invite survives restart and leaving consent abandons only setup',
'saved pending account retains invite across retry without reopening setup',
(tester, f) async {
await f.initialize();
// /me reports stored details as pending even when legacy screening
// standing still says MISSING + NOT_SCREENED.
f.screeningPending = true;
// If setup is incorrectly reopened, allow its callback to finish so the
// no-browser assertion fails instead of cycling until the test times out.
f.beforeBrowserReturn = () async => f.screeningPending = false;
await f.mount(tester);
await f.link(tester, 'one');
expect(f.browserStarts, 0);
expect(f.sent('/claim'), isEmpty);
expect(await f.storage.loadAll(), isEmpty);
expect(find.text('Accept and join').hitTestable(), findsOneWidget);
await f.restart(tester);
await f.tap(tester, 'Start login again');
expect(find.text('Accept and join').hitTestable(), findsOneWidget);
expect(f.browserStarts, 0);
f.screeningPending = false;
expect(
f.sent('/api/join-policy').last.url.host,
'one.communities.buzz.xyz',

View File

@@ -63,6 +63,9 @@ class HostedJourneyFixture {
int starterFailures = 0;
final policyResponses = <http.Response>[];
int browserStarts = 0;
final browserUrls = <Uri>[];
bool needsAccountSetup = false;
bool screeningPending = false;
int secondRelayFailures = 0;
DateTime assertionNow = DateTime.now();
bool relayAuthEnabled = false;
@@ -143,7 +146,20 @@ class HostedJourneyFixture {
};
} else if (path.endsWith('/me')) {
if (expiredSession) return http.Response('{}', 401);
result = {'subject': subject, 'expires_at': expiry};
result = {
'subject': subject,
'expires_at': expiry,
'account_status': needsAccountSetup
? 'ACCOUNT_STATUS_NEEDS_DETAILS'
: screeningPending
? 'ACCOUNT_STATUS_PENDING'
: 'ACCOUNT_STATUS_OK',
if (needsAccountSetup || screeningPending)
'account_standing': {
'details_status': 'ACCOUNT_DETAILS_STATUS_MISSING',
'screening_status': 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED',
},
};
} else if (path.endsWith('/identity/assertions')) {
await beforeAssertionReturn?.call();
if (expiredSession) return http.Response('{}', 401);
@@ -369,7 +385,8 @@ class HostedJourneyFixture {
HostedFlow createFlow(Ref ref) => activeFlow = createHostedFlow(
ref,
browserAuth: _Browser(() async {
browserAuth: _Browser((uri) async {
browserUrls.add(uri);
browserStarts++;
await beforeBrowserReturn?.call();
}),
@@ -428,7 +445,7 @@ class HostedJourneyFixture {
final signIn = OrganizationSignIn(
account: ref.watch(organizationAccountProvider)!,
browser: _Browser(
() async => organizationBrowserStarts++,
(_) async => organizationBrowserStarts++,
origin: organizationOrigin,
),
// As in production: only the organization's relay reconnects.
@@ -664,10 +681,10 @@ class _Recovery implements InviteJoinRecovery {
}
class _Browser extends BrowserAuth {
_Browser(Future<void> Function() onStart, {super.origin})
_Browser(Future<void> Function(Uri) onStart, {super.origin})
: super(
openBrowser: (uri) async {
await onStart();
await onStart(uri);
return Uri.parse(BrowserAuth.callback)
.replace(
queryParameters: {

View File

@@ -34,6 +34,20 @@ void main() {
'code': 'exchange-code',
},
);
// A temporary backend failure must surface retry without losing the invite.
await restored.receive(
callback.replace(
queryParameters: {
'state': login.queryParameters['state']!,
'error': 'temporarily_unavailable',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(codes, isEmpty);
expect(failures, [BrowserAuthFailure.restartLogin]);
expect(await restored.retainedIntent(), containsPair('code', 'invite'));
failures.clear();
await restored.receive(callback);
await restored.receive(callback);
await Future<void>.delayed(Duration.zero);
@@ -48,6 +62,17 @@ void main() {
);
expect(login.queryParameters['code_challenge_method'], 'S256');
expect(login.origin, HostedEnvironment.origin.origin);
expect(login.path, '/auth/native');
expect(
login.queryParametersAll.keys,
unorderedEquals([
'returnTo',
'state',
'code_challenge',
'code_challenge_method',
]),
);
expect(login.queryParametersAll.values, everyElement(hasLength(1)));
final stored = await const FlutterSecureStorage().readAll();
expect(stored.keys, everyElement(contains(HostedEnvironment.origin.host)));
expect(login.queryParameters['returnTo'], BrowserAuth.callback);