mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(mobile): read exact selected mention capability evidence
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart';
|
||||
part 'agent_policy.dart';
|
||||
part 'agent_authorization.dart';
|
||||
part 'agent_publication.dart';
|
||||
part 'selected_mention_authorization.dart';
|
||||
|
||||
/// A relay agent parsed from its kind:10100 agent-profile event.
|
||||
///
|
||||
|
||||
@@ -24,11 +24,14 @@ Future<List<NostrEvent>> _queryAgentFilters(
|
||||
|
||||
/// Overlay current owner-authenticated policy onto the existing runtime
|
||||
/// directory. This does not expand discovery to owner-only coordinates yet.
|
||||
/// [onProfileEvidence] observes immutable latest query heads after the final
|
||||
/// scope check. It does not resolve ownership or mutate any shared profile cache.
|
||||
Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrEvent> runtimeEvents, {
|
||||
Set<String>? requestedKeys,
|
||||
void Function()? checkCurrent,
|
||||
void Function(Map<String, NostrEvent> profiles)? onProfileEvidence,
|
||||
}) async {
|
||||
final latest = <String, NostrEvent>{};
|
||||
for (final event in runtimeEvents.where((event) => event.kind == 10100)) {
|
||||
@@ -64,6 +67,7 @@ Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
|
||||
),
|
||||
], checkCurrent: checkCurrent);
|
||||
checkCurrent?.call();
|
||||
onProfileEvidence?.call(Map.unmodifiable(profiles));
|
||||
return mergeAgentPolicies(latest.values, policies, owners);
|
||||
}
|
||||
|
||||
|
||||
173
mobile/lib/shared/mentions/selected_mention_authorization.dart
Normal file
173
mobile/lib/shared/mentions/selected_mention_authorization.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
part of 'agent_identity_provider.dart';
|
||||
|
||||
/// Evidence requirements, not a claim that an ordinary identity is human.
|
||||
enum SelectedMentionKind { ordinary, agent, unresolvedAgent }
|
||||
|
||||
/// Fresh evidence for one exact selected key. This is NOT permission to write:
|
||||
/// consumers must apply their existing agent eligibility evaluator to [agent],
|
||||
/// normal channel invitation permission, consent and scope fences separately.
|
||||
/// An existing member needs no role write, whatever their current role is.
|
||||
class SelectedMentionAuthorization {
|
||||
final SelectedMentionKind kind;
|
||||
final bool isMember;
|
||||
|
||||
/// Policy projection with relay-authenticated destination membership only.
|
||||
/// Null for ordinary identities or unresolved prior-agent evidence. Missing
|
||||
/// owner policy produces a deny-all entry, never runtime fallback.
|
||||
final AgentDirectoryEntry? agent;
|
||||
|
||||
const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent);
|
||||
|
||||
bool get requiresAgentAuthorization => kind != SelectedMentionKind.ordinary;
|
||||
|
||||
/// Role selection only, never an invitation authorization. A consumer must
|
||||
/// first validate eligibility and consent and skip writes for existing members.
|
||||
String? get invitationRole => switch (kind) {
|
||||
SelectedMentionKind.ordinary => 'member',
|
||||
SelectedMentionKind.agent => 'bot',
|
||||
SelectedMentionKind.unresolvedAgent => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Read all selected keys, not just candidates whose saved isAgent bit is true.
|
||||
/// [priorAgentKeys] is denial-only taint: lost provenance cannot turn a saved
|
||||
/// agent capability into an ordinary-member invitation or notification bypass.
|
||||
/// Absence of agent evidence permits the existing ordinary membership flow,
|
||||
/// NOT a humanity assertion. Positive fresh evidence overrides saved false.
|
||||
///
|
||||
/// Every requested key has a result or the entire read throws. This read makes
|
||||
/// no cache writes and does not opt into resolveProfileOwners. Check [isCurrent]
|
||||
/// again before each side effect; re-read after consent and accepted invitations.
|
||||
Future<Map<String, SelectedMentionAuthorization>>
|
||||
readSelectedMentionAuthorization(
|
||||
RelaySessionNotifier session,
|
||||
Set<String> requestedKeys, {
|
||||
required String viewer,
|
||||
required String channelId,
|
||||
Set<String> priorAgentKeys = const {},
|
||||
required bool Function() isCurrent,
|
||||
}) async {
|
||||
void check() {
|
||||
if (!isCurrent()) throw StateError('Mention authorization scope changed');
|
||||
}
|
||||
|
||||
check();
|
||||
if (requestedKeys.length > 1000 ||
|
||||
!requestedKeys.containsAll(priorAgentKeys) ||
|
||||
requestedKeys.any((key) => !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) ||
|
||||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(viewer) ||
|
||||
channelId.isEmpty) {
|
||||
throw StateError('Invalid selected mention request');
|
||||
}
|
||||
if (requestedKeys.isEmpty) return const {};
|
||||
final authority = await session.fetchRelaySelf();
|
||||
check();
|
||||
final membership = await _membershipPages(
|
||||
session,
|
||||
authority,
|
||||
viewer,
|
||||
channelId,
|
||||
check,
|
||||
);
|
||||
check();
|
||||
NostrEvent? roster;
|
||||
for (final event in membership) {
|
||||
if (event.kind != 39002 ||
|
||||
event.pubkey != authority ||
|
||||
event.getTagValue('d') != channelId) {
|
||||
continue;
|
||||
}
|
||||
if (roster == null || _newer(event, roster)) roster = event;
|
||||
}
|
||||
// A malformed newest head is unavailable, never permission to invite someone
|
||||
// whose current membership could not be established. Missing head is likewise
|
||||
// not a trustworthy empty roster at this destination.
|
||||
if (roster == null ||
|
||||
!verifySignedEvent(roster) ||
|
||||
roster.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1) {
|
||||
throw StateError('Destination membership unavailable');
|
||||
}
|
||||
final members = <String, String>{};
|
||||
for (final tag in roster.tags.where((t) => t.isNotEmpty && t[0] == 'p')) {
|
||||
if (tag.length < 2 || members.containsKey(tag[1])) {
|
||||
throw StateError('Ambiguous destination membership');
|
||||
}
|
||||
members[tag[1]] = tag.length >= 4 ? tag[3] : 'member';
|
||||
}
|
||||
final runtime = await _queryAgentFilters(session, [
|
||||
for (final key in requestedKeys)
|
||||
NostrFilter(kinds: const [10100], authors: [key], limit: 1),
|
||||
], checkCurrent: check);
|
||||
check();
|
||||
Map<String, NostrEvent> profiles = const {};
|
||||
final policies = await resolveAgentPolicies(
|
||||
session,
|
||||
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
|
||||
requestedKeys: requestedKeys,
|
||||
checkCurrent: check,
|
||||
onProfileEvidence: (value) => profiles = value,
|
||||
);
|
||||
check();
|
||||
final latestRuntime = <String, NostrEvent>{};
|
||||
for (final event in runtime) {
|
||||
if (event.kind != 10100 || !requestedKeys.contains(event.pubkey)) continue;
|
||||
final previous = latestRuntime[event.pubkey];
|
||||
if (previous == null || _newer(event, previous)) {
|
||||
latestRuntime[event.pubkey] = event;
|
||||
}
|
||||
}
|
||||
final byKey = {for (final policy in policies) policy.pubkey: policy};
|
||||
final result = <String, SelectedMentionAuthorization>{};
|
||||
for (final key in requestedKeys) {
|
||||
final profile = profiles[key];
|
||||
final owner = profile == null ? null : verifiedOaOwnerPubkey(profile);
|
||||
final invalidProfile =
|
||||
profile != null &&
|
||||
(!verifySignedEvent(profile) ||
|
||||
(owner == null &&
|
||||
profile.tags.any((t) => t.isNotEmpty && t[0] == 'auth')));
|
||||
final runtimeHead = latestRuntime[key];
|
||||
final knownAgent =
|
||||
owner != null ||
|
||||
members[key] == 'bot' ||
|
||||
(runtimeHead != null && verifySignedEvent(runtimeHead));
|
||||
final unresolved =
|
||||
invalidProfile ||
|
||||
(runtimeHead != null && !verifySignedEvent(runtimeHead)) ||
|
||||
(!knownAgent && priorAgentKeys.contains(key));
|
||||
final kind = unresolved
|
||||
? SelectedMentionKind.unresolvedAgent
|
||||
: knownAgent
|
||||
? SelectedMentionKind.agent
|
||||
: SelectedMentionKind.ordinary;
|
||||
AgentDirectoryEntry? agent;
|
||||
if (kind == SelectedMentionKind.agent) {
|
||||
final policy = byKey[key];
|
||||
// Ownership without a matching valid policy must not revive headless
|
||||
// runtime authority. mergeAgentPolicies remains the sole policy parser.
|
||||
final missingOwnedPolicy = owner != null && policy?.ownerPubkey != owner;
|
||||
agent = AgentDirectoryEntry(
|
||||
pubkey: key,
|
||||
ownerPubkey: owner,
|
||||
displayName: policy?.displayName,
|
||||
respondTo: missingOwnedPolicy ? 'nobody' : policy?.respondTo,
|
||||
respondToAllowlist: missingOwnedPolicy
|
||||
? const []
|
||||
: policy?.respondToAllowlist ?? const [],
|
||||
channelIds:
|
||||
members.containsKey(viewer) &&
|
||||
members.containsKey(key) &&
|
||||
(owner == viewer || members[key] == 'bot')
|
||||
? [channelId]
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
result[key] = SelectedMentionAuthorization._(
|
||||
kind,
|
||||
members.containsKey(key),
|
||||
agent,
|
||||
);
|
||||
}
|
||||
check();
|
||||
return Map.unmodifiable(result);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../crypto/nip_oa_test.dart' show authTag, profile;
|
||||
import 'agent_policy_test.dart' show PolicySession, signed;
|
||||
|
||||
class _Session extends PolicySession {
|
||||
_Session(super.events, this.authority, {super.failPolicy});
|
||||
final String authority;
|
||||
void Function(List<NostrFilter>)? onQuery;
|
||||
@override
|
||||
Future<String> fetchRelaySelf() async => authority;
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
onQuery?.call(filters);
|
||||
return super.queryRelay(filters, timeout: timeout);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final viewer = nostr.Keys.generate();
|
||||
final person = nostr.Keys.generate();
|
||||
final agent = nostr.Keys.generate();
|
||||
final relay = nostr.Keys.generate();
|
||||
final owned = profile(agent, [authTag(viewer, agent.public)]);
|
||||
final runtime = signed(agent, 10100, {'respond_to': 'anyone'});
|
||||
NostrEvent roster({String? role, bool personMember = true, int time = 100}) =>
|
||||
signed(
|
||||
relay,
|
||||
39002,
|
||||
'',
|
||||
time: time,
|
||||
tags: [
|
||||
['d', 'room'],
|
||||
['p', viewer.public],
|
||||
if (personMember) ['p', person.public, '', 'member'],
|
||||
if (role != null) ['p', agent.public, '', role],
|
||||
],
|
||||
);
|
||||
NostrEvent policy(String mode, {int time = 100}) => signed(
|
||||
viewer,
|
||||
30177,
|
||||
{'name': 'Agent', 'parallelism': 1, 'respond_to': mode},
|
||||
time: time,
|
||||
tags: [
|
||||
['d', agent.public],
|
||||
],
|
||||
);
|
||||
|
||||
Future<Map<String, SelectedMentionAuthorization>> read(
|
||||
List<NostrEvent> events, {
|
||||
Set<String> prior = const {},
|
||||
bool Function()? current,
|
||||
_Session? session,
|
||||
Set<String>? keys,
|
||||
}) async {
|
||||
final source = session ?? _Session(events, relay.public);
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => source)],
|
||||
);
|
||||
container.read(relaySessionProvider);
|
||||
try {
|
||||
return await readSelectedMentionAuthorization(
|
||||
source,
|
||||
keys ?? {person.public, agent.public},
|
||||
viewer: viewer.public,
|
||||
channelId: 'room',
|
||||
priorAgentKeys: prior,
|
||||
isCurrent: current ?? () => true,
|
||||
);
|
||||
} finally {
|
||||
container.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'exact total map preserves ordinary member and nonmember flows',
|
||||
() async {
|
||||
for (final member in [true, false]) {
|
||||
final result = await read([roster(personMember: member)]);
|
||||
expect(result.keys.toSet(), {person.public, agent.public});
|
||||
final ordinary = result[person.public]!;
|
||||
expect(ordinary.kind, SelectedMentionKind.ordinary);
|
||||
expect(ordinary.requiresAgentAuthorization, isFalse);
|
||||
expect(ordinary.isMember, member);
|
||||
expect(ordinary.invitationRole, 'member');
|
||||
expect(() => result.clear(), throwsUnsupportedError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'saved false cannot exclude fresh agent or select member role',
|
||||
() async {
|
||||
final result = await read([
|
||||
roster(role: 'member'),
|
||||
owned,
|
||||
policy('anyone'),
|
||||
]);
|
||||
final fresh = result[agent.public]!;
|
||||
expect(fresh.kind, SelectedMentionKind.agent);
|
||||
expect(fresh.requiresAgentAuthorization, isTrue);
|
||||
expect(fresh.invitationRole, 'bot');
|
||||
expect(fresh.isMember, isTrue);
|
||||
expect(fresh.agent!.ownerPubkey, viewer.public);
|
||||
expect(fresh.agent!.channelIds, ['room']);
|
||||
expect(result[person.public]!.kind, SelectedMentionKind.ordinary);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'saved true with lost provenance remains unresolved even as member',
|
||||
() async {
|
||||
final result = await read(
|
||||
[roster(role: 'member'), owned, profile(agent, [], createdAt: 101)],
|
||||
prior: {agent.public},
|
||||
);
|
||||
final lost = result[agent.public]!;
|
||||
expect(lost.kind, SelectedMentionKind.unresolvedAgent);
|
||||
expect(lost.isMember, isTrue);
|
||||
expect(lost.requiresAgentAuthorization, isTrue);
|
||||
expect(lost.agent, isNull);
|
||||
expect(lost.invitationRole, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'missing and revoked owner policy deny without runtime fallback',
|
||||
() async {
|
||||
for (final policies in <List<NostrEvent>>[
|
||||
[],
|
||||
[policy('anyone'), policy('nobody', time: 101)],
|
||||
]) {
|
||||
final result = await read([
|
||||
roster(role: 'bot'),
|
||||
owned,
|
||||
runtime,
|
||||
...policies,
|
||||
]);
|
||||
final fresh = result[agent.public]!;
|
||||
expect(fresh.kind, SelectedMentionKind.agent);
|
||||
expect(fresh.agent!.respondTo, 'nobody');
|
||||
expect(fresh.agent!.ownerPubkey, viewer.public);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'known runtime agent without membership never becomes ordinary',
|
||||
() async {
|
||||
final result = await read([roster(), runtime]);
|
||||
expect(result[agent.public]!.kind, SelectedMentionKind.agent);
|
||||
expect(result[agent.public]!.agent!.channelIds, isEmpty);
|
||||
expect(result[agent.public]!.isMember, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'headless bot requires runtime policy; malformed OA is unresolved',
|
||||
() async {
|
||||
final bot = (await read([roster(role: 'bot')]))[agent.public]!;
|
||||
expect(bot.kind, SelectedMentionKind.agent);
|
||||
expect(bot.agent!.respondTo, isNull);
|
||||
final good = (await read([roster(role: 'bot'), runtime]))[agent.public]!;
|
||||
expect(good.agent!.respondTo, 'anyone');
|
||||
expect(good.agent!.channelIds, ['room']);
|
||||
final bad = (await read([
|
||||
roster(role: 'bot'),
|
||||
runtime,
|
||||
profile(agent, [
|
||||
['auth', viewer.public, '', 'bad'],
|
||||
]),
|
||||
]))[agent.public]!;
|
||||
expect(bad.kind, SelectedMentionKind.unresolvedAgent);
|
||||
expect(bad.agent, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'newest roster removal wins; invalid head does not revive old member',
|
||||
() async {
|
||||
final result = await read([
|
||||
roster(role: 'bot'),
|
||||
roster(time: 101),
|
||||
runtime,
|
||||
]);
|
||||
expect(result[agent.public]!.isMember, isFalse);
|
||||
expect(result[agent.public]!.agent!.channelIds, isEmpty);
|
||||
final invalid = NostrEvent.fromJson({
|
||||
...roster(time: 102).toJson(),
|
||||
'content': 'tampered',
|
||||
});
|
||||
await expectLater(read([roster(), invalid]), throwsStateError);
|
||||
await expectLater(read([]), throwsStateError);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'policy failures and scope retirement throw rather than partial map',
|
||||
() async {
|
||||
await expectLater(
|
||||
read(
|
||||
[],
|
||||
session: _Session([roster(), owned], relay.public, failPolicy: true),
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
var current = true;
|
||||
final session = _Session([roster(), owned], relay.public)
|
||||
..onQuery = (filters) {
|
||||
if (filters.any((f) => f.kinds.contains(0))) current = false;
|
||||
};
|
||||
await expectLater(
|
||||
read([], session: session, current: () => current),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(session.queries.any((f) => f.kinds.contains(30177)), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('exact request bounds and denial-only taint are validated', () async {
|
||||
await expectLater(read([roster()], keys: {'not-a-key'}), throwsStateError);
|
||||
await expectLater(
|
||||
read([roster()], keys: {person.public}, prior: {agent.public}),
|
||||
throwsStateError,
|
||||
);
|
||||
final keys = {
|
||||
for (var i = 0; i < 1001; i++) i.toRadixString(16).padLeft(64, '0'),
|
||||
};
|
||||
await expectLater(read([roster()], keys: keys), throwsStateError);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user