Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions mobile/lib/shared/mentions/agent_authorization.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
part of 'agent_identity_provider.dart';

/// Fresh exact-key policy and relay-signed membership. Directory entries are
/// hints; only this read may authorize agent mentions at a destination.
Future<List<AgentDirectoryEntry>> readAgentAuthorization(
RelaySessionNotifier session,
Set<String> requestedKeys, {
required String? viewer,
String? channelId,
required bool Function() isCurrent,
}) async {
void check() {
if (!isCurrent()) throw StateError('Agent authorization scope changed');
}

check();
if (requestedKeys.isEmpty) return [];
if (viewer == null ||
requestedKeys.length > 1000 ||
requestedKeys.any((key) => !RegExp(r'^[0-9a-f]{64}$').hasMatch(key))) {
throw StateError('Invalid agent authorization request');
}
final authority = await session.fetchRelaySelf();
check();
var membership = await _membershipPages(
session,
authority,
viewer,
channelId,
check,
);
if (channelId == null) {
// #p discovers destinations, not authority: a replacement removing the
// viewer cannot match that filter. Re-read each exact coordinate without
// #p, and never retain a discovery snapshot if its head is absent.
final destinations = membership
.where((e) => e.kind == 39002 && e.pubkey == authority)
.map((e) => e.getTagValue('d'))
.whereType<String>()
.toSet();
if (destinations.length > 1000) {
throw StateError('Membership destination budget exceeded');
}
membership = await _queryAgentFilters(session, [
for (final destination in destinations)
NostrFilter(
kinds: const [39002],
authors: [authority],
tags: {
'#d': [destination],
},
limit: 1,
),
], checkCurrent: check);
}
check();
final runtime = await _queryAgentFilters(session, [
for (final key in requestedKeys)
NostrFilter(kinds: const [10100], authors: [key], limit: 1),
], checkCurrent: check);
check();
// Guard every query stage, not merely the result: a mutable session must not
// continue an old account/community request against the newly selected relay.
final agents = await resolveAgentPolicies(
session,
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
requestedKeys: requestedKeys,
checkCurrent: check,
);
check();
final latest = <String, NostrEvent>{};
for (final event in membership) {
if (event.kind != 39002 || event.pubkey != authority) {
continue;
}
final destination = event.getTagValue('d');
if (destination == null ||
(channelId != null && destination != channelId)) {
continue;
}
final previous = latest[destination];
if (previous == null || _newer(event, previous)) {
latest[destination] = event;
}
}
final result = <AgentDirectoryEntry>[];
for (final agent in agents) {
final owned = agent.ownerPubkey == viewer;
final channels = <String>[];
for (final entry in latest.entries) {
if (!verifySignedEvent(entry.value) ||
entry.value.tags
.where((tag) => tag.isNotEmpty && tag[0] == 'd')
.length !=
1) {
continue;
}
final people = entry.value.tags.where(
(tag) => tag.length >= 2 && tag[0] == 'p',
);
if (!people.any((tag) => tag[1] == viewer)) continue;
if (people.any(
(tag) =>
tag[1] == agent.pubkey &&
(owned || (tag.length >= 4 && tag[3] == 'bot')),
)) {
channels.add(entry.key);
}
}
if (!owned && channels.isEmpty) continue;
result.add(
AgentDirectoryEntry(
pubkey: agent.pubkey,
ownerPubkey: agent.ownerPubkey,
displayName: agent.displayName,
respondTo: agent.respondTo,
respondToAllowlist: agent.respondToAllowlist,
channelIds: channels,
),
);
}
return result;
}

Future<List<NostrEvent>> _membershipPages(
RelaySessionNotifier session,
String authority,
String viewer,
String? channelId,
void Function() check,
) async {
final result = <NostrEvent>[];
NostrEvent? cursor;
for (var page = 0; page < 200; page++) {
check();
final events = await session.queryRelay([
NostrFilter(
kinds: const [39002],
authors: [authority],
tags: {
if (channelId == null) '#p': [viewer],
if (channelId != null) '#d': [channelId],
},
limit: 500,
until: cursor?.createdAt,
extensions: {if (cursor != null) 'before_id': cursor.id},
),
]);
check();
result.addAll(events);
if (events.length < 500) return result;
final next = events.last;
if (cursor != null &&
(next.createdAt > cursor.createdAt ||
(next.createdAt == cursor.createdAt &&
next.id.compareTo(cursor.id) <= 0))) {
throw StateError('Membership pagination did not advance');
}
cursor = next;
}
throw StateError('Membership query exceeded its page budget');
}
17 changes: 16 additions & 1 deletion mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '../../shared/crypto/signed_event.dart';
import '../../shared/relay/relay.dart';

part 'agent_policy.dart';
part 'agent_authorization.dart';

/// A relay agent parsed from its kind:10100 agent-profile event.
///
Expand All @@ -18,6 +19,9 @@ part 'agent_policy.dart';
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;

/// Owner authenticated by the agent's verified NIP-OA kind:0 attestation,
/// not a runtime ownership claim. Null means no authenticated owner here.
final String? ownerPubkey;
final String? respondTo;
final List<String> respondToAllowlist;
Expand Down Expand Up @@ -70,8 +74,19 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const [];
final session = ref.read(relaySessionProvider.notifier);
final config = ref.read(relayConfigProvider);
var disposed = false;
ref.onDispose(() => disposed = true);
final viewer = ref.read(myPubkeyProvider);
final events = await session.fetchHistory(NostrFilters.agentProfiles());
return resolveAgentPolicies(session, events);
if (disposed) return [];
return readAgentAuthorization(
session,
events.map((event) => event.pubkey).toSet(),
viewer: viewer,
isCurrent: () =>
!disposed && identical(config, ref.read(relayConfigProvider)),
);
});

/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
Expand Down
23 changes: 15 additions & 8 deletions mobile/lib/shared/mentions/agent_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ bool _newer(NostrEvent event, NostrEvent previous) =>
/// is distinct from a failed read; failures must never revive runtime access.
Future<List<NostrEvent>> _queryAgentFilters(
RelaySessionNotifier session,
List<NostrFilter> filters,
) async {
List<NostrFilter> filters, {
void Function()? checkCurrent,
}) async {
final events = <NostrEvent>[];
for (var start = 0; start < filters.length; start += 10) {
checkCurrent?.call();
events.addAll(
await session.queryRelay(filters.skip(start).take(10).toList()),
);
Expand All @@ -24,28 +26,32 @@ Future<List<NostrEvent>> _queryAgentFilters(
/// directory. This does not expand discovery to owner-only coordinates yet.
Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
RelaySessionNotifier session,
List<NostrEvent> runtimeEvents,
) async {
List<NostrEvent> runtimeEvents, {
Set<String>? requestedKeys,
void Function()? checkCurrent,
}) async {
final latest = <String, NostrEvent>{};
for (final event in runtimeEvents.where((event) => event.kind == 10100)) {
final previous = latest[event.pubkey];
if (previous == null || _newer(event, previous)) {
latest[event.pubkey] = event;
}
}
final keys = requestedKeys ?? latest.keys.toSet();
final profiles = latestProfileEvents(
await _queryAgentFilters(session, [
for (final key in latest.keys)
for (final key in keys)
NostrFilter(kinds: const [0], authors: [key], limit: 1),
]),
], checkCurrent: checkCurrent),
);
final owners = <String, String>{};
for (final profile in profiles.values) {
final owner = verifiedOaOwnerPubkey(profile);
if (owner != null && latest.containsKey(profile.pubkey)) {
if (owner != null && keys.contains(profile.pubkey)) {
owners[profile.pubkey] = owner;
}
}
checkCurrent?.call();
final policies = await _queryAgentFilters(session, [
for (final owner in owners.entries)
NostrFilter(
Expand All @@ -56,7 +62,8 @@ Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
},
limit: 1,
),
]);
], checkCurrent: checkCurrent);
checkCurrent?.call();
return mergeAgentPolicies(latest.values, policies, owners);
}

Expand Down
23 changes: 23 additions & 0 deletions mobile/lib/shared/relay/relay_http_query_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ class RelayHttpQueryClient {
_ClientGeneration? _currentGeneration;
final Set<_ClientGeneration> _generations = {};

/// Read relay metadata over the same owned transport as query requests.
Future<http.Response> get(
Uri url, {
required Map<String, String> headers,
required Duration timeout,
}) async {
final generation = _injectedClient == null
? (_currentGeneration ??= _createGeneration())
: null;
generation?.acquire();
try {
return await (_injectedClient ?? generation!.client)
.get(url, headers: headers)
.timeout(timeout);
} on TimeoutException {
if (identical(_currentGeneration, generation)) _currentGeneration = null;
generation?.retire();
rethrow;
} finally {
generation?.release();
}
}

Future<http.Response> post(
Uri url, {
required Map<String, String> headers,
Expand Down
19 changes: 19 additions & 0 deletions mobile/lib/shared/relay/relay_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,25 @@ class RelaySessionNotifier extends Notifier<SessionState> {
return const SessionState(status: SessionStatus.disconnected);
}

/// NIP-11 `self` is the membership signer; `pubkey` is only contact metadata.
Future<String> fetchRelaySelf() async {
final config = ref.read(relayConfigProvider);
final response = await _httpQueryClient.get(
Uri.parse(config.baseUrl),
headers: const {'Accept': 'application/nostr+json'},
timeout: const Duration(seconds: 8),
);
if (response.statusCode != 200) {
throw RelayException(response.statusCode, 'Relay authority unavailable');
}
final data = jsonDecode(response.body);
final key = data is Map ? data['self'] : null;
if (key is! String || !RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(key)) {
throw const FormatException('Relay membership authority unavailable');
}
return key.toLowerCase();
}

/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Expand Down
Loading
Loading