mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Adapt staging remote-agent prototype to landed account authentication
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -12,7 +12,7 @@ import 'prototype/remote_agent_controller.dart';
|
||||
import 'prototype/simulator_browser.dart';
|
||||
import 'shared/hosted/account_identity_store.dart';
|
||||
import 'shared/hosted/browser_auth.dart';
|
||||
import 'shared/hosted/builderlab_account.dart';
|
||||
import 'shared/hosted/hosted_relay_auth.dart';
|
||||
import 'shared/identity_transfer/nsec_import_sheet.dart';
|
||||
import 'shared/theme/app_theme.dart';
|
||||
import 'shared/theme/grid.dart';
|
||||
@@ -38,7 +38,8 @@ class RemotePrototype extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useMemoized(
|
||||
() => RemoteAgentController(
|
||||
account: BuilderlabAccountClient(),
|
||||
account: ref.read(builderlabAccountProvider),
|
||||
relayAuth: ref.read(hostedRelayAuthProvider),
|
||||
browser: BrowserAuth(
|
||||
openBrowser: kDebugMode ? openSimulatorChrome : null,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Throwaway staging remote-agent prototype
|
||||
|
||||
This is a separate mobile entry point on Wes's mobile-onboarding branch. It uses the existing hosted-account session and identity-binding code, then the desktop BuilderLab Beekeeper API to register and activate an agent.
|
||||
This is a separate mobile entry point rebased onto main `139f8903d5fd075a172b7555d88ae0e616cdcb39`, including the final authentication implementation from PR #8375 (`b6aa5539e3a44fe32a4bfe2963dfb23f10316985`). It uses the existing hosted-account session and identity-binding code, then the desktop BuilderLab Beekeeper API to register and activate an agent.
|
||||
|
||||
Entry point: `lib/main_prototype.dart`. Required build define: `BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz`. The running test app has bundle identifier `xyz.block.buzz.dev.mobile.remoteprototype` and display name **Buzz Remote Prototype**.
|
||||
Entry point: `lib/main_prototype.dart`. Required build defines: `BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz`, `BUZZ_AUTH_DOMAINS=communities.public.builderlabstaging.xyz`, and `BUZZ_HOSTED_RELAY_AUTH=true`. The running test app has bundle identifier `xyz.block.buzz.dev.mobile.remoteprototype` and display name **Buzz Remote Prototype**.
|
||||
|
||||
## Simulator use
|
||||
|
||||
@@ -12,10 +12,12 @@ Entry point: `lib/main_prototype.dart`. Required build define: `BUZZ_BUILDERLAB_
|
||||
4. Tap **Create remote agent**. If an operation fails, **Resume agent setup** reuses the saved registration ID and idempotency key.
|
||||
5. Open the community in Buzz to interact with the agent.
|
||||
|
||||
The prototype imports an existing owner identity; it does not implement new-user key backup or recovery. A debug-only native method transfers a one-time simulator launch environment value into device-only Keychain. It is compiled only for a debug simulator and checks the prototype bundle identifier. No key is embedded in source or build configuration.
|
||||
The prototype reuses an existing owner identity only after the current account binding confirms the same key. It adopts the saved key into the account-scoped slot without deleting the old record. Community admission now uses the shared encrypted-backup, binding verification, and relay probe services. It does not implement new-user identity selection or recovery UI. A debug-only native method transfers a one-time simulator launch environment value into device-only Keychain. It is compiled only for a debug simulator and checks the prototype bundle identifier. No key is embedded in source or build configuration.
|
||||
|
||||
The agent's private key remains on BuilderLab. Activation uses the owner's NIP-OA authorization, explicitly subscribes the agent to the selected community, and publishes a relay listing with owner-only responses and parallelism 1. Attestation alone only reconciles server-configured communities, so it is insufficient for a newly created community. The link button beside an existing agent idempotently subscribes it to the selected community. Pending setup contains public identifiers only and is scoped to the staging account.
|
||||
|
||||
This is a local experiment, not a production onboarding implementation. Browser account sign-in, live registration, relay admission and agent response must all be verified before treating the end-to-end flow as working.
|
||||
|
||||
Verified on 2026-10-16: Chrome sign-in, existing Tom identity binding, disposable public staging community creation, agent registration/attestation/subscription, and a real reply in the private `prototype-test` channel. Agent `8397ca0794853c62d53c910775ac316474ec7f577bde8c5199091c15dae47953` replied “Remote agent is ready.” to event `7246a58737e32d1f7d60d75747c15f254dafa0b8c2742ecf6ac73485909671b4`. No production state was changed.
|
||||
|
||||
Adaptation source was recovered from Codex startup-cleanup snapshot `902ca75bae20e91d153acc40b3245e8f3e6384b8`; the original branch is preserved. Current worktree: `/Users/tomb/.codex/worktrees/remote-prototype-current/buzz`, branch `remote-prototype-current`. The app and prototype share one account client. Discovery uses the joined-community API; both agent listing publication and chat use federated relay authentication. The October 16 evidence above is historical, not verification of this adaptation.
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../shared/community/community.dart';
|
||||
import '../shared/community/community_storage.dart';
|
||||
import '../shared/deeplink/deep_link.dart';
|
||||
import '../shared/hosted/account_identity_store.dart';
|
||||
@@ -15,7 +14,11 @@ import '../shared/hosted/browser_auth.dart';
|
||||
import '../shared/hosted/builderlab_account.dart';
|
||||
import '../shared/hosted/hosted_communities.dart';
|
||||
import '../shared/hosted/hosted_environment.dart';
|
||||
import '../shared/hosted/web_connect_binding.dart';
|
||||
import '../shared/hosted/hosted_admission.dart';
|
||||
import '../shared/hosted/hosted_backup_service.dart';
|
||||
import '../shared/hosted/hosted_community_creation.dart';
|
||||
import '../shared/hosted/hosted_relay_auth.dart';
|
||||
import '../shared/relay/relay_provider.dart';
|
||||
import '../shared/relay/signed_event_relay.dart';
|
||||
|
||||
/// Staging-only experiment using the same account and Beekeeper contracts as desktop.
|
||||
@@ -23,11 +26,18 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
RemoteAgentController({
|
||||
required this.account,
|
||||
required this.browser,
|
||||
required this.keys,
|
||||
});
|
||||
required AccountIdentityStore keys,
|
||||
required this.relayAuth,
|
||||
}) : _keys = keys;
|
||||
final BuilderlabAccountClient account;
|
||||
final BrowserAuth browser;
|
||||
final AccountIdentityStore keys;
|
||||
final AccountIdentityStore _keys;
|
||||
final HostedRelayAuth relayAuth;
|
||||
AccountIdentityStore get keys {
|
||||
final subject = account.account?.subject;
|
||||
return subject == null ? _keys : _keys.forSubject(subject);
|
||||
}
|
||||
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
bool busy = false;
|
||||
int _operation = 0;
|
||||
@@ -104,12 +114,13 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
});
|
||||
|
||||
Future<void> refresh() async {
|
||||
owner = (await keys.read())?.pubkey;
|
||||
final session = account.account;
|
||||
if (session == null) {
|
||||
status = 'Sign in to your staging BuilderLab account.';
|
||||
return;
|
||||
}
|
||||
await adoptBoundIdentity();
|
||||
owner = (await keys.read())?.pubkey;
|
||||
_scope =
|
||||
'remote-prototype:${HostedEnvironment.origin.host}:${session.subject}';
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -141,12 +152,10 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
|
||||
Future<void> createTestCommunity() => run(() async {
|
||||
await ensureBinding();
|
||||
final response = await account.post('/v1/buzz/communities', {
|
||||
'name': 'tomb-remote-prototype-20261016',
|
||||
});
|
||||
if (response['error'] != null) {
|
||||
throw StateError('Staging community creation was not confirmed.');
|
||||
}
|
||||
final subject = account.account!.subject;
|
||||
await HostedCreateCommunity(
|
||||
'tomb-remote-prototype-20261016',
|
||||
).provision(account, owner!, () => account.account?.subject == subject);
|
||||
await refresh();
|
||||
if (communities.isEmpty) {
|
||||
throw StateError(
|
||||
@@ -165,86 +174,40 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> ensureBinding() async {
|
||||
/// Migrate only a locally saved key confirmed by the current server binding.
|
||||
/// Keep the original record and refuse account/key replacement.
|
||||
Future<void> adoptBoundIdentity() async {
|
||||
final subject = account.account?.subject;
|
||||
final identity = await keys.read();
|
||||
if (subject == null || identity == null) {
|
||||
throw StateError(
|
||||
'Sign in and transfer your existing Buzz identity first.',
|
||||
);
|
||||
if (subject == null) throw AccountFailure.signedOut;
|
||||
final scoped = _keys.forSubject(subject);
|
||||
final response = await account.currentIdentity();
|
||||
if (account.account?.subject != subject) {
|
||||
throw AccountFailure.differentAccount;
|
||||
}
|
||||
final current = await account.currentIdentity();
|
||||
final bound = (current['identity'] as Map?)?['pubkey_hex'];
|
||||
if (bound == identity.pubkey) return;
|
||||
if (bound != null) {
|
||||
throw StateError(
|
||||
'This account is bound to a different Buzz identity. Nothing was replaced.',
|
||||
);
|
||||
if (response['error'] != null) throw AccountFailure.invalidResponse;
|
||||
final bound = (response['identity'] as Map?)?['pubkey_hex'];
|
||||
final local = await scoped.read();
|
||||
if (local != null) {
|
||||
if (bound != local.pubkey) throw AccountIdentityFailure.mismatch;
|
||||
return;
|
||||
}
|
||||
if (current['error'] != null &&
|
||||
(current['error'] as Map)['setup_needed'] != true) {
|
||||
throw StateError(
|
||||
'BuilderLab could not verify the existing identity binding.',
|
||||
);
|
||||
}
|
||||
final challenge = await account.post(
|
||||
'/v1/buzz/nostr-identities/challenge',
|
||||
{'origin': HostedEnvironment.origin.toString()},
|
||||
);
|
||||
final fields = <String, String>{
|
||||
'audience': 'buzz:nostr-identity',
|
||||
'action': 'bind_nostr_identity',
|
||||
'protocol': 'buzz-nostr-identity',
|
||||
'version': '1',
|
||||
'return': 'browser_fragment_v1',
|
||||
'callback_url': HostedEnvironment.origin.resolve('/buzz').toString(),
|
||||
};
|
||||
for (final field in [
|
||||
'challenge_id',
|
||||
'nonce',
|
||||
'verification_code',
|
||||
'origin',
|
||||
'expires_at',
|
||||
]) {
|
||||
if (challenge[field] is! String) {
|
||||
throw StateError('Invalid identity challenge.');
|
||||
}
|
||||
fields[field] = challenge[field] as String;
|
||||
}
|
||||
final request = parseNostrBindDeepLink(
|
||||
Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: fields),
|
||||
);
|
||||
if (request == null) throw StateError('Invalid identity challenge.');
|
||||
await WebConnectBindingService().complete(
|
||||
request: request,
|
||||
subject: subject,
|
||||
expectedPubkey: identity.pubkey,
|
||||
withKeys: keys.withKeys,
|
||||
verify:
|
||||
({
|
||||
required subject,
|
||||
required challengeId,
|
||||
required nonce,
|
||||
required signedPayload,
|
||||
}) async {
|
||||
if (account.account?.subject != subject) {
|
||||
throw StateError('Account changed.');
|
||||
}
|
||||
final result = await account
|
||||
.post('/v1/buzz/nostr-identities/verify', {
|
||||
'challenge_id': challengeId,
|
||||
'nonce': nonce,
|
||||
'signed_payload': signedPayload,
|
||||
});
|
||||
final key = (result['identity'] as Map?)?['pubkey_hex'];
|
||||
if (key is! String) {
|
||||
throw StateError('Identity binding was not confirmed.');
|
||||
}
|
||||
return key;
|
||||
},
|
||||
if (bound is! String) throw AccountIdentityFailure.absent;
|
||||
await scoped.adoptSaved(
|
||||
bound,
|
||||
checkCurrent: () {
|
||||
if (account.account?.subject != subject) {
|
||||
throw AccountFailure.differentAccount;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> ensureBinding() async {
|
||||
await adoptBoundIdentity();
|
||||
owner = (await keys.read())?.pubkey;
|
||||
if (owner == null) throw AccountIdentityFailure.absent;
|
||||
}
|
||||
|
||||
Future<void> _savePending(Map<String, dynamic> value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!await prefs.setString('$_scope:pending', jsonEncode(value))) {
|
||||
@@ -313,6 +276,9 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
await keys.withKeys((key) async {
|
||||
await submitSignedEventOnce(
|
||||
wsUrl: selected.relayUrl,
|
||||
headers: await relayAuth.headers(
|
||||
RelayConfig(baseUrl: selected.relayUrl, nsec: key.nsec),
|
||||
),
|
||||
nsec: key.nsec,
|
||||
kind: 30177,
|
||||
content: jsonEncode({
|
||||
@@ -354,25 +320,32 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
Future<void> saveCommunity() async {
|
||||
final selected = community;
|
||||
if (selected == null) throw StateError('Choose a community first.');
|
||||
await keys.withKeys((key) async {
|
||||
final subject = account.account?.subject;
|
||||
if (subject == null || owner == null) throw AccountFailure.signedOut;
|
||||
await HostedBackupService(
|
||||
account,
|
||||
keys,
|
||||
).backup(isCurrent: () => account.account?.subject == subject);
|
||||
final admission = HostedAdmission(
|
||||
account: account,
|
||||
store: _keys,
|
||||
relayAuth: relayAuth,
|
||||
);
|
||||
try {
|
||||
final prepared = await admission.prepare(selected, subject, owner!);
|
||||
final storage = CommunityStorage();
|
||||
final existing = (await storage.loadAll())
|
||||
.where((c) => c.relayUrl == selected.relayUrl)
|
||||
.firstOrNull;
|
||||
if (existing != null && existing.pubkey != key.public) {
|
||||
if (existing != null && existing.pubkey != prepared.pubkey) {
|
||||
throw StateError('Saved community has another identity.');
|
||||
}
|
||||
final saved =
|
||||
existing ??
|
||||
Community.create(
|
||||
name: selected.name,
|
||||
relayUrl: selected.relayUrl,
|
||||
nsec: key.nsec,
|
||||
pubkey: key.public,
|
||||
);
|
||||
final saved = existing ?? prepared;
|
||||
await storage.save(saved);
|
||||
await storage.saveActiveId(saved.id);
|
||||
});
|
||||
} finally {
|
||||
admission.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -381,7 +354,6 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
unawaited(browser.dispose());
|
||||
account.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ Future<NostrEvent> submitSignedEventOnce({
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
Map<String, String> headers = const {},
|
||||
}) async {
|
||||
final privateKey = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privateKey.isEmpty) throw const FormatException('Invalid nsec');
|
||||
@@ -122,6 +123,7 @@ Future<NostrEvent> submitSignedEventOnce({
|
||||
}
|
||||
},
|
||||
);
|
||||
socket.headers = headers;
|
||||
final resultFuture = result.future.timeout(timeout);
|
||||
try {
|
||||
await socket.connect();
|
||||
|
||||
46
mobile/test/prototype/relay_headers_test.dart
Normal file
46
mobile/test/prototype/relay_headers_test.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:buzz/shared/relay/signed_event_relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
test('prototype publication carries federated admission header', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final received = server.first.then((request) async {
|
||||
expect(
|
||||
request.headers.value('Nostr-Federated-Identity'),
|
||||
'Bearer fixture',
|
||||
);
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
socket.add(jsonEncode(['AUTH', 'fixture-challenge']));
|
||||
socket.listen((raw) {
|
||||
final frame = jsonDecode(raw as String) as List;
|
||||
final event = frame[1] as Map;
|
||||
if (frame.first == 'AUTH') {
|
||||
socket.add(jsonEncode(['OK', event['id'], true, '']));
|
||||
} else {
|
||||
expect(frame.first, 'EVENT');
|
||||
expect(event['kind'], 30177);
|
||||
socket.add(jsonEncode(['OK', event['id'], true, '']));
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
});
|
||||
final key = nostr.Keys('0' * 63 + '1');
|
||||
try {
|
||||
await submitSignedEventOnce(
|
||||
wsUrl: 'ws://127.0.0.1:${server.port}',
|
||||
nsec: key.nsec,
|
||||
kind: 30177,
|
||||
content: '{}',
|
||||
tags: const [],
|
||||
headers: const {'Nostr-Federated-Identity': 'Bearer fixture'},
|
||||
);
|
||||
await (await received).close();
|
||||
} finally {
|
||||
await server.close(force: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,67 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:buzz/prototype/remote_agent_controller.dart';
|
||||
import 'package:buzz/shared/crypto/nip_oa.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
test(
|
||||
'verified binding migrates legacy key without replacing original',
|
||||
() async {
|
||||
final key = nostr.Keys('0' * 63 + '1');
|
||||
final store = AccountIdentityStore();
|
||||
await store.create(key.nsec);
|
||||
final account = BoundAccount(key.public);
|
||||
final auth = HostedRelayAuth(account);
|
||||
final controller = RemoteAgentController(
|
||||
account: account,
|
||||
browser: BrowserAuth(),
|
||||
keys: store,
|
||||
relayAuth: auth,
|
||||
);
|
||||
await controller.adoptBoundIdentity();
|
||||
expect(
|
||||
(await store.forSubject('prototype-owner').read())!.pubkey,
|
||||
key.public,
|
||||
);
|
||||
expect((await store.read())!.pubkey, key.public);
|
||||
expect((await controller.keys.read())!.backup, isNull);
|
||||
controller.dispose();
|
||||
auth.dispose();
|
||||
account.dispose();
|
||||
},
|
||||
);
|
||||
test('different bound identity cannot acquire legacy key', () async {
|
||||
final key = nostr.Keys('0' * 63 + '1');
|
||||
final other = nostr.Keys('0' * 63 + '2');
|
||||
final store = AccountIdentityStore();
|
||||
await store.create(key.nsec);
|
||||
final account = BoundAccount(other.public);
|
||||
final auth = HostedRelayAuth(account);
|
||||
final controller = RemoteAgentController(
|
||||
account: account,
|
||||
browser: BrowserAuth(),
|
||||
keys: store,
|
||||
relayAuth: auth,
|
||||
);
|
||||
await expectLater(
|
||||
controller.adoptBoundIdentity(),
|
||||
throwsA(AccountIdentityFailure.absent),
|
||||
);
|
||||
expect(await store.forSubject('prototype-owner').read(), isNull);
|
||||
expect((await store.read())!.pubkey, key.public);
|
||||
controller.dispose();
|
||||
auth.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
|
||||
test('owner proof verifies for only the provisioned agent key', () {
|
||||
final owner = nostr.Keys('0' * 63 + '1');
|
||||
final agent = nostr.Keys('0' * 63 + '2');
|
||||
@@ -27,3 +85,17 @@ void main() {
|
||||
expect(() => requireStatus({}, 'ACTIVE', 1), throwsStateError);
|
||||
});
|
||||
}
|
||||
|
||||
class BoundAccount extends BuilderlabAccountClient {
|
||||
BoundAccount(this.pubkey);
|
||||
final String pubkey;
|
||||
@override
|
||||
BuilderlabAccount get account => BuilderlabAccount(
|
||||
subject: 'prototype-owner',
|
||||
expiresAt: DateTime.utc(2030),
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> currentIdentity() async => {
|
||||
'identity': {'pubkey_hex': pubkey},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user