Files
buzz/mobile/lib/shared/hosted/hosted_environment.dart
Tom Brow ab516337a5 feat(mobile): open a prefilled Create agent form from Builderlab agent cards
A printed Builderlab agent card's QR leads to its share page
(app.builderlab.xyz/a/<id>), whose "Add to Buzz" button opens
buzz://agent/new?card=<id>. Mobile now handles that link: it fetches the
card's agent.json from the avatars CDN for this build's Builderlab
environment and opens the Agents page with an editable, prefilled Create
agent form. Opening a link never creates an agent.

- shared/deeplink: strict parser for buzz://agent/new?card=<token>; any
  other shape is not this link.
- HostedEnvironment.avatarsCdnOrigin maps each account environment to its
  CDN; the link can never name the host.
- AgentCreationDraft.fetch reads name, avatarUrl, instructions, model and
  reasoningEffort. Every field is optional: published cards have only name
  and avatar today. snake_case and a few synonyms are also read so a later
  agent.json revision degrades to a partial draft instead of failing. The
  avatar is used only from the same CDN; the body is capped at 256 KiB.
- AgentsPage waits for sign-in, the registry load and remote-agent support,
  drops the link with a message if a setup is unfinished or the card can't
  be loaded, and replaces an open Agents stack rather than stacking one.
- CreateAgentPage takes an optional draft; model and effort apply only if
  the live catalog offers them.
- Table tests for the link and agent.json, and a journey test from link to
  Create against a fake CDN (needs BUZZ_AUTH_DOMAINS, like the other hosted
  agent tests).

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
2026-11-02 09:10:32 -08:00

109 lines
3.9 KiB
Dart

/// Trusted build configuration, never supplied by a deep link or relay.
abstract final class HostedEnvironment {
/// Account UI and startup work are opt-in, independently of the backend URL.
static bool get enabled => authDomains.isNotEmpty;
static final Uri origin = _origin();
static const _communityAccounts = {
'communities.buzz.xyz': 'https://app.builderlab.xyz',
'communities.public.builderlabstaging.xyz':
'https://app.builderlabstaging.xyz',
};
/// Public CDN serving each published Builderlab agent card's
/// `<id>/agent.json` and images, for this build's account environment.
static Uri get avatarsCdnOrigin => Uri.parse(switch (origin.host) {
'app.builderlab.xyz' => 'https://d2z0pbxrxbsiz5.cloudfront.net',
'app.builderlabstaging.xyz' => 'https://d10hx6rb3fdt3l.cloudfront.net',
_ => throw StateError('Unsupported hosted account environment'),
});
/// Shortens hostname fallbacks for display without changing relay identity.
static String communityDisplayName(String name, String relayUrl) {
final relay = Uri.tryParse(relayUrl);
if (relay == null ||
name != relay.host ||
accountOriginFor(relay) == null) {
return name;
}
for (final domain in _communityAccounts.keys) {
final suffix = '.$domain';
if (name.endsWith(suffix)) {
return name.substring(0, name.length - suffix.length);
}
}
return name;
}
/// Creation and admission use the same trusted build-specific host mapping.
static String get communitySuffix => _communityAccounts.entries
.singleWhere((entry) => Uri.parse(entry.value) == origin)
.key;
/// Comma-separated DNS domains requiring Builderlab authentication.
/// Empty builds keep Nostr-only entry available, including hosted QR pairing.
static final List<String> authDomains = List.unmodifiable(
const String.fromEnvironment('BUZZ_AUTH_DOMAINS')
.split(',')
.map(
(domain) =>
domain.trim().toLowerCase().replaceFirst(RegExp(r'\.+$'), ''),
)
.where((domain) => domain.isNotEmpty)
.map((domain) {
if (!RegExp(
r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$',
).hasMatch(domain)) {
throw StateError(
'BUZZ_AUTH_DOMAINS must contain DNS names, not URLs or wildcards',
);
}
return domain;
}),
);
/// Matches a configured domain and its subdomains, never a string suffix.
static bool requiresAuth(String host) {
final normalized = host.toLowerCase().replaceFirst(RegExp(r'\.+$'), '');
return authDomains.any(
(domain) => normalized == domain || normalized.endsWith('.$domain'),
);
}
/// Explicit community-to-account mapping. Never infer an account endpoint
/// from arbitrary relay metadata or send this build's session across realms.
static Uri? accountOriginFor(Uri relay) {
if (!['wss', 'https'].contains(relay.scheme) ||
relay.userInfo.isNotEmpty ||
relay.hasQuery ||
relay.hasFragment ||
(relay.path.isNotEmpty && relay.path != '/') ||
(relay.hasPort && relay.port != 443)) {
return null;
}
final host = relay.host.toLowerCase();
for (final entry in _communityAccounts.entries) {
if (host.endsWith('.${entry.key}')) return Uri.parse(entry.value);
}
return null;
}
/// Whether this build can use its account session for this relay.
static bool supportsRelay(Uri relay) => accountOriginFor(relay) == origin;
static Uri _origin() {
const configured = String.fromEnvironment(
'BUZZ_BUILDERLAB_URL',
defaultValue: 'https://app.builderlab.xyz',
);
final uri = Uri.parse(configured);
if (!{
'https://app.builderlab.xyz',
'https://app.builderlabstaging.xyz',
}.contains(configured)) {
throw StateError('Unsupported hosted account environment');
}
return uri;
}
}