Files
buzz/mobile/lib/shared/deeplink/deep_link.dart
Tom Brow fa395e9e80 feat(mobile): open a prefilled Create agent form from Builderlab agent cards (#8912)
Builderlab agent cards (printed for the event, and generated at the
kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its
**Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz
client handled until now. This PR makes mobile handle it.

### Behavior

- Opening the link shows the Agents page, then a Create agent form
prefilled from the card. Everything stays editable, and nothing is
created until the user taps Create.
- The app fetches the card's `agent.json` from the avatars CDN for this
build's Builderlab environment (production or staging). The link carries
only the card id, never a host, so the QR stays small no matter how long
a card's instructions get.
- `agent.json` is validated against the card schema. `id` (matching the
link), `name` and `avatarUrl` (an https URL) are required.
`instructions`, `model` and `reasoningEffort` are optional (published
cards don't have them yet) and left for the user when absent. A missing
required field or a wrongly typed known field rejects the card; any
other key is ignored whatever its type, including `v` and `description`
(Buzz agents have no description to prefill), so Builderlab can add
fields without breaking released apps. The model and effort are
preselected only if the live catalog offers them. The card id is opaque:
any non-empty id is accepted and percent-encoded into the URL.
- The fetch uses the same limits as image downloads, now shared in
`bounded_fetch.dart`: aborted after 20 s without a byte or 5 min
overall, and rejected over 2 MB. `MediaImageProvider` now calls the same
helper, with no behavior change.
- The link waits for sign-in, then reloads the registry for the
signed-in account before anything else. It's dropped with a message if
the card is unavailable (403/404), invalid, can't be loaded (any other
response), or an agent setup is unfinished, including a setup saved
before a sign-in the link triggered.
- A link that arrives while Agents shows only its list replaces that
page instead of stacking a second one. If a form or sheet is open there,
the link is dropped with "Finish or close what’s open in Agents, then
reopen the link." and the open work is kept.

### Demo

Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in
fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden
debug banner from [#8915](https://github.com/block/buzz/pull/8915),
signed in to Builderlab, remote-agent-pilot), recorded in one take with
idle time trimmed: the card's share page in Safari, Add to Buzz, the
prefilled Create agent form, a model, thinking level and instructions,
Create, then a message and the agent's reply.


https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d

### Validation

Added tests:
- Table tests for the link shape (only `?card=<id>` with an opaque id;
the inline `#` form, extra parameters and dot segments are rejected) and
for reading `agent.json` (a published card, a card with instructions and
model defaults, unknown keys of any type ignored, including `v`,
`description`, arrays, objects and null, and invalid cards: missing or
mismatched required fields, wrong types, and non-https or relative
avatars).
- Table tests for fetching: the request goes to this build's CDN without
following redirects, with the id percent-encoded as one path segment;
403/404, a redirect, a 500, a network error, declared and streamed
oversize bodies, non-JSON and a JSON array each give a specific message.
The image loader's existing stall, deadline, cap and cancel tests now
cover the shared helper.
- A journey test from link to Create against a fake CDN: prefill and
edit, then submit; an unavailable card; a failed model catalog whose
Retry then preselects the card's model; a Builderlab account change
while the card loads, which drops the draft; a v1 card with no
instructions and an unknown model; an unfinished setup; a link arriving
while a Create form with edits is open, which keeps the form and its
edits; and a link arriving while Agents is open. Disabling the https or
id check makes the table test fail, and removing either the account
recheck or the loaded-catalog check fails the journey. The journey runs
through the production root scope (removing its link handler fails it)
and runs in CI's configured-authentication-domains step.

Full mobile check and test suite pass locally.

Live data: a real staging card's `agent.json`
(`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and
an unknown id returns 403 from the CDN.

Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in
enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a
real production card,
[ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ)
(a v1 card with no instructions and an extra `description` key):
- Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold
start opens Create agent prefilled with the card's name and avatar.
- After choosing a model and adding instructions, Create adds the agent
with the card avatar, and it replies in a DM.
- An unknown card id shows "This agent card isn’t available."

| Prefilled | Created | Reply | Unknown card |
| --- | --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png"
width="200"> |

Before this PR, the app had no handler for `buzz://agent/new`, so the
link did nothing.

A separate widget test covers a link that needs Builderlab sign-in
first: once sign-in lands, a setup saved for that account blocks the
card, which is never fetched. Removing the registry reload fails it, as
does removing the busy-Agents guard for the journey step above.

### Before merge

- [x] Human live check waived under the repository owner's standing
testing exception. In its place, agent-driven full-app runs exercised
the flow end to end: the iPhone SE device run and the simulator demo
above (a real card's share page, **Add to Buzz**, the prefilled form,
Create and a reply from the agent). Limitation: those runs predate
[638f5ecac7](638f5ecac7),
whose two guards are covered by the widget tests above rather than a
live run.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:00:08 -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,
);
}