Files
buzz/mobile/lib/shared/deeplink/deep_link.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

472 lines
14 KiB
Dart

/// Parsing for `buzz://` deep links.
///
/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
/// `buzz://message?channel=<uuid>&id=<hex>[&thread=<hex>]` references a
/// message (optionally inside a thread) in a channel. Required params that
/// are missing or empty make the link invalid — the caller never sees a
/// half-formed target.
library;
import '../relay/relay_validation.dart';
import '../hosted/hosted_environment.dart';
part 'agent_creation_link.dart';
/// A parsed deep link supported by the app.
sealed class BuzzDeepLink {
const BuzzDeepLink();
/// Community addressed by entry links; other actions have no relay target.
String? get relayUrl => null;
}
/// A parsed relay invite link.
///
/// Canonical share links are `https://<relay>/invite/<code>`. The custom
/// `buzz://join?relay=<ws(s)://relay>&code=<code>` form is only an installed-app
/// handoff from the web landing page.
class InviteDeepLink extends BuzzDeepLink {
/// Relay URL normalized to the websocket scheme used by the app.
@override
final String relayUrl;
/// Invite code from the link.
final String code;
/// Optional receipt proving acceptance of the relay's current join policy.
final String? policyReceipt;
const InviteDeepLink({
required this.relayUrl,
required this.code,
this.policyReceipt,
});
@override
bool operator ==(Object other) =>
other is InviteDeepLink &&
other.relayUrl == relayUrl &&
other.code == code &&
other.policyReceipt == policyReceipt;
@override
int get hashCode => Object.hash(relayUrl, code, policyReceipt);
@override
String toString() =>
'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
}
/// A parsed channel-only deep link.
///
/// Canonical form: `buzz://channel/<channel-uuid>`.
class ChannelDeepLink extends BuzzDeepLink {
/// Channel UUID from the sole path segment.
final String channelId;
const ChannelDeepLink({required this.channelId});
@override
bool operator ==(Object other) =>
other is ChannelDeepLink && other.channelId == channelId;
@override
int get hashCode => channelId.hashCode;
@override
String toString() => 'ChannelDeepLink(channel: $channelId)';
}
/// A parsed `buzz://message` deep link.
class MessageDeepLink extends BuzzDeepLink {
/// Local community identifier for notification-originated links.
/// Canonical shared links omit this because community IDs are device-local.
final String? communityId;
/// Channel UUID from the `channel` query param.
final String channelId;
/// Event ID (hex) from the `id` query param.
final String messageId;
/// Optional thread root event ID from the `thread` query param.
final String? threadRootId;
const MessageDeepLink({
this.communityId,
required this.channelId,
required this.messageId,
this.threadRootId,
});
@override
bool operator ==(Object other) =>
other is MessageDeepLink &&
other.communityId == communityId &&
other.channelId == channelId &&
other.messageId == messageId &&
other.threadRootId == threadRootId;
@override
int get hashCode =>
Object.hash(communityId, channelId, messageId, threadRootId);
@override
String toString() =>
'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, '
'thread: $threadRootId)';
}
/// Build a canonical `buzz://message` link for a channel message.
///
/// Mirrors `desktop/src/features/messages/lib/messageLink.ts` so links copied
/// or shared from mobile round-trip through every client's parser:
/// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`.
///
/// An empty [threadRootId] is treated as "no thread" so callers can pass
/// through a nullable thread reference without extra checks.
String buildMessageLink({
required String channelId,
required String messageId,
String? threadRootId,
}) {
if (channelId.isEmpty) {
throw ArgumentError('buildMessageLink: channelId is required');
}
if (messageId.isEmpty) {
throw ArgumentError('buildMessageLink: messageId is required');
}
final params = <String, String>{
'channel': channelId,
'id': messageId,
if (threadRootId != null && threadRootId.isNotEmpty) 'thread': threadRootId,
};
return Uri(
scheme: 'buzz',
host: 'message',
queryParameters: params,
).toString();
}
/// Parse a canonical `buzz://channel/<channel-uuid>` URI.
///
/// The channel ID must be the URI's sole non-empty path segment. Query
/// parameters and fragments are rejected so malformed or ambiguous links never
/// become navigation targets.
ChannelDeepLink? parseChannelDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'channel') return null;
if (uri.hasQuery ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
if (uri.pathSegments.length != 1 || uri.pathSegments.single.isEmpty) {
return null;
}
final channelId = uri.pathSegments.single;
if (!RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
).hasMatch(channelId)) {
return null;
}
return ChannelDeepLink(channelId: channelId.toLowerCase());
}
/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
///
/// Returns `null` unless the URI exactly matches the canonical message-link
/// shape: no path, fragment, credentials, duplicate or unknown parameters; a
/// UUID channel; and 64-character hexadecimal message/thread event IDs.
MessageDeepLink? parseMessageDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'message') return null;
if (uri.path.isNotEmpty ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
const allowedParams = {'channel', 'id', 'thread'};
if (uri.queryParametersAll.keys.any((key) => !allowedParams.contains(key)) ||
uri.queryParametersAll.values.any((values) => values.length != 1)) {
return null;
}
final channel = uri.queryParameters['channel'];
final id = uri.queryParameters['id'];
final thread = uri.queryParameters['thread'];
final uuid = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
);
final eventId = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false);
if (channel == null ||
!uuid.hasMatch(channel) ||
id == null ||
!eventId.hasMatch(id) ||
(thread != null && !eventId.hasMatch(thread))) {
return null;
}
return MessageDeepLink(
channelId: channel.toLowerCase(),
messageId: id.toLowerCase(),
threadRootId: thread?.toLowerCase(),
);
}
/// Parse canonical HTTPS invite links and `buzz://join` app handoffs.
///
/// Accepted forms:
/// - `https://<relay>/invite/<code>` -> `wss://<relay>` + code
/// - `http://localhost/invite/<code>` -> `ws://localhost` + code in debug builds
/// - `buzz://join?relay=<wss://relay>&code=<code>` -> relay + code
/// - `buzz://join?relay=<ws://localhost>&code=<code>` -> local relay in debug
///
/// Rejects credentials, fragments, missing params, nested relay credentials, and
/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
/// community admission links.
InviteDeepLink? parseInviteDeepLink(Uri uri) {
if (uri.hasFragment || uri.userInfo.isNotEmpty) return null;
if (uri.scheme == 'buzz') {
if (uri.host != 'join') return null;
final relay = uri.queryParameters['relay'];
final code = uri.queryParameters['code'];
if (relay == null || relay.isEmpty || code == null || code.isEmpty) {
return null;
}
final relayUri = Uri.tryParse(relay);
if (relayUri == null ||
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss') ||
relayUri.host.isEmpty ||
relayUri.userInfo.isNotEmpty ||
relayUri.hasFragment) {
return null;
}
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final normalizedRelay = Uri(
scheme: relayUri.scheme,
host: relayUri.host,
port: relayUri.hasPort ? relayUri.port : null,
).toString();
final policyReceipt = uri.queryParameters['policy_receipt'];
return InviteDeepLink(
relayUrl: normalizedRelay,
code: code,
policyReceipt: policyReceipt == null || policyReceipt.isEmpty
? null
: policyReceipt,
);
}
if (uri.scheme == 'https' || uri.scheme == 'http') {
if (uri.host.isEmpty) return null;
final segments = uri.pathSegments;
if (segments.length != 2 ||
segments[0] != 'invite' ||
segments[1].isEmpty) {
return null;
}
final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final relayUri = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
);
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final relay = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
return InviteDeepLink(relayUrl: relay, code: segments[1]);
}
return null;
}
/// Parse any supported Buzz deep link.
BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
parseAgentCreationDeepLink(uri) ??
parseNostrBindDeepLink(uri) ??
parseAddCommunityDeepLink(uri) ??
parseInviteDeepLink(uri) ??
parseChannelDeepLink(uri) ??
parseMessageDeepLink(uri);
/// A validated Buzz repository, pull request, or issue permalink.
class EntityDeepLink extends BuzzDeepLink {
final String type;
final String owner;
final String repository;
final String? eventId;
const EntityDeepLink({
required this.type,
required this.owner,
required this.repository,
this.eventId,
});
}
/// Parse canonical `buzz://repo|pr|issue` permalinks for inline presentation.
EntityDeepLink? parseEntityDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || !{'repo', 'pr', 'issue'}.contains(uri.host)) {
return null;
}
if (uri.path.isNotEmpty ||
uri.hasFragment ||
uri.userInfo.isNotEmpty ||
uri.hasPort) {
return null;
}
final allowed = uri.host == 'repo' ? {'owner', 'd'} : {'id', 'owner', 'd'};
final queryParameters = uri.queryParametersAll;
final parameterKeys = queryParameters.keys.toSet();
if (parameterKeys.difference(allowed).isNotEmpty ||
allowed.difference(parameterKeys).isNotEmpty ||
allowed.any((key) => queryParameters[key]?.length != 1)) {
return null;
}
final owner = uri.queryParameters['owner'];
final repository = uri.queryParameters['d'];
final eventId = uri.queryParameters['id'];
final hex = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false);
final repositoryName = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$');
if (owner == null ||
!hex.hasMatch(owner) ||
repository == null ||
!repositoryName.hasMatch(repository) ||
repository.contains('..')) {
return null;
}
if (uri.host != 'repo' && (eventId == null || !hex.hasMatch(eventId))) {
return null;
}
return EntityDeepLink(
type: uri.host,
owner: owner.toLowerCase(),
repository: repository,
eventId: eventId?.toLowerCase(),
);
}
/// Web signup's challenge. Values are untrusted until authenticated verification.
class NostrBindDeepLink extends BuzzDeepLink {
final Map<String, String> fields;
NostrBindDeepLink._(Map<String, String> fields)
: fields = Map.unmodifiable(fields);
String get challengeId => fields['challenge_id']!;
String get nonce => fields['nonce']!;
DateTime get expiresAt => DateTime.parse(fields['expires_at']!);
@override
String toString() => 'NostrBindDeepLink(redacted)';
}
class AddCommunityDeepLink extends BuzzDeepLink {
@override
final String relayUrl;
final String _name;
/// User-facing label, shortening hosted host-name fallbacks like [Community].
String get name => HostedEnvironment.communityDisplayName(_name, relayUrl);
const AddCommunityDeepLink({required this.relayUrl, required String name})
: _name = name;
}
bool _webHandoffShape(Uri uri, String host, Set<String> allowed) =>
uri.scheme == 'buzz' &&
uri.host == host &&
uri.path.isEmpty &&
!uri.hasFragment &&
!uri.hasPort &&
uri.userInfo.isEmpty &&
uri.toString().length <= 8192 &&
uri.queryParametersAll.keys.every(allowed.contains) &&
uri.queryParametersAll.values.every((values) => values.length == 1);
NostrBindDeepLink? parseNostrBindDeepLink(Uri uri) {
const fields = {
'challenge_id',
'nonce',
'verification_code',
'audience',
'action',
'protocol',
'version',
'origin',
'expires_at',
'return',
'callback_url',
};
if (!_webHandoffShape(uri, 'nostr-bind', fields) ||
!fields.every(uri.queryParameters.containsKey)) {
return null;
}
final p = uri.queryParameters;
if (!RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
).hasMatch(p['challenge_id']!) ||
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(p['nonce']!) ||
!RegExp(r'^[0-9]{6}$').hasMatch(p['verification_code']!) ||
p['audience'] != 'buzz:nostr-identity' ||
p['action'] != 'bind_nostr_identity' ||
p['protocol'] != 'buzz-nostr-identity' ||
p['version'] != '1' ||
p['origin'] != HostedEnvironment.origin.toString() ||
p['return'] != 'browser_fragment_v1' ||
p['callback_url'] !=
HostedEnvironment.origin.resolve('/buzz').toString() ||
!RegExp(r'(Z|[+-][0-9]{2}:[0-9]{2})$').hasMatch(p['expires_at']!) ||
DateTime.tryParse(p['expires_at']!) == null) {
return null;
}
return NostrBindDeepLink._(p);
}
AddCommunityDeepLink? parseAddCommunityDeepLink(Uri uri) {
if (!_webHandoffShape(uri, 'add-community', {'relay', 'name'})) return null;
final relay = Uri.tryParse(uri.queryParameters['relay'] ?? '');
final name = uri.queryParameters['name'];
if (relay == null ||
relay.scheme != 'wss' ||
relay.host.isEmpty ||
relay.host.endsWith('.') ||
relay.userInfo.isNotEmpty ||
relay.hasQuery ||
relay.hasFragment ||
(relay.path.isNotEmpty && relay.path != '/') ||
name == null ||
name.trim().isEmpty ||
name.length > 200) {
return null;
}
try {
validateInviteRelayUri(relay);
} on FormatException {
return null;
}
return AddCommunityDeepLink(
relayUrl: Uri(
scheme: 'wss',
host: relay.host,
port: relay.hasPort ? relay.port : null,
).toString(),
name: name,
);
}