refactor(trust): ego-centric trust replaces the global Duniter membership

The global membership rule (curated bootstrap referents + sigQty/stepMax
parameters) solved sybil-proof identity for a UBI — a problem this app
doesn't have — and its screen leaked graph jargon (npub roots, parameter
steppers). Trust is now computed from the user's own position only:
you vouch / vouched by people you know (distance <=2) / vouched by N.

- TrustCubit: drop networkMember tier, referents and params; keep the
  circle rule and the 365-day vouch expiry (renewable, self-pruning).
- Delete TrustReferents, WotSettings, TrustNetworkScreen and the bundled
  referents asset; unwire injector/bootstrap/app/chat.
- New 'Your people' screen (/your-people, from the profile): who you
  vouch for (revocable) and who vouches for you, names via ProfileCache.
- i18n: wot.* removed, yourPeople.* added (en/es/pt/ast); trust.member
  removed. Kind 30777 events on relays stay fully compatible — only the
  interpretation changes.
This commit is contained in:
vjrj 2026-07-11 13:03:20 +02:00
parent 4af90876f4
commit dc7b2eee27
25 changed files with 595 additions and 863 deletions

View file

@ -1,45 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/trust_referents.dart';
import '../support/test_support.dart';
void main() {
const npub =
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
const hex =
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
// No bundled asset in the unit test bundle _bundled() degrades to empty, so
// these exercise the user-added set (the cold-start bootstrap path).
late TrustReferents referents;
setUp(() => referents = TrustReferents(InMemorySecretStore()));
test('starts empty', () async {
expect(await referents.all(), isEmpty);
expect(await referents.userAdded(), isEmpty);
});
test('adds a referent from an npub, storing hex', () async {
final stored = await referents.add(npub);
expect(stored, hex);
expect(await referents.userAdded(), {hex});
expect(await referents.all(), contains(hex));
});
test('accepts a hex key directly and is idempotent', () async {
await referents.add(hex);
await referents.add(npub); // same identity, npub form
expect(await referents.userAdded(), {hex});
});
test('rejects an invalid identity code', () async {
expect(() => referents.add('not-a-key'), throwsFormatException);
expect(await referents.userAdded(), isEmpty);
});
test('removes a referent', () async {
await referents.add(hex);
await referents.remove(hex);
expect(await referents.userAdded(), isEmpty);
});
}

View file

@ -1,28 +0,0 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/wot_settings.dart';
import '../support/test_support.dart';
void main() {
late WotSettings settings;
setUp(() => settings = WotSettings(InMemorySecretStore()));
test('defaults to the Duniter parameters', () async {
expect(await settings.params(), WotParams.duniter);
});
test('persists saved parameters', () async {
const custom = WotParams(
sigQty: 3, stepMax: 4, sigValidity: Duration(days: 180));
await settings.save(custom);
expect(await settings.params(), custom);
});
test('reset restores the Duniter defaults', () async {
await settings.save(const WotParams(
sigQty: 1, stepMax: 1, sigValidity: Duration(days: 30)));
await settings.reset();
expect(await settings.params(), WotParams.duniter);
});
}

View file

@ -83,6 +83,7 @@ void main() {
final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
await inCircle.load();
expect(inCircle.state.knownToYou, isTrue);
expect(inCircle.state.tier, TrustTier.inYourCircle);
await inCircle.close();
final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me);
@ -91,39 +92,6 @@ void main() {
await outside.close();
});
test('network membership: enough referent vouches → member', () async {
const params =
WotParams(sigQty: 2, stepMax: 5, sigValidity: Duration(days: 365));
final transport = FakeTrustTransport(me)
..addCert('r1', peer)
..addCert('r2', peer);
final cubit = TrustCubit(
transport,
peerPubkey: peer,
selfPubkey: me,
referents: {'r1', 'r2'},
params: params,
);
await cubit.load();
expect(cubit.state.networkBootstrapped, isTrue);
expect(cubit.state.isNetworkMember, isTrue);
expect(cubit.state.tier, TrustTier.networkMember);
await cubit.close();
});
test('with no referents, membership is undetermined (not a member)',
() async {
final transport = FakeTrustTransport(me)
..addCert('r1', peer)
..addCert('r2', peer);
// referents default to {} the trust net isn't seeded.
final cubit = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
await cubit.load();
expect(cubit.state.networkBootstrapped, isFalse);
expect(cubit.state.isNetworkMember, isFalse);
await cubit.close();
});
test('tier falls back to vouched, then unknown', () async {
final vouched = TrustCubit(
FakeTrustTransport(me)..addCert('someone', peer),

View file

@ -0,0 +1,174 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/profile_cache.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/ui/your_people_screen.dart';
import '../support/test_support.dart';
/// In-memory [TrustTransport] with a mutable certification graph.
class FakeTrustTransport implements TrustTransport {
FakeTrustTransport(this.selfId);
final String selfId;
final List<Certification> certs = [];
void addCert(String issuer, String subject) => certs.add(Certification(
issuer: issuer,
subject: subject,
issuedAt: DateTime(2026),
));
@override
Future<List<Certification>> allCertifications() async => List.of(certs);
@override
Future<Set<String>> certifiersOf(String subjectPubkey) async => {
for (final c in certs)
if (c.subject == subjectPubkey) c.issuer,
};
@override
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
}) async =>
addCert(selfId, subjectPubkey);
@override
Future<void> revoke({required String subjectPubkey}) async => certs
.removeWhere((c) => c.issuer == selfId && c.subject == subjectPubkey);
@override
Future<void> close() async {}
}
/// A [SocialSession] stand-in that only carries the trust transport the
/// screen under test never touches the other transports.
class FakeSession implements SocialSession {
FakeSession(this.trust);
@override
final TrustTransport trust;
@override
OfferTransport get offers => throw UnimplementedError();
@override
MessageTransport get messages => throw UnimplementedError();
@override
ProfileTransport get profile => throw UnimplementedError();
@override
SyncTransport get sync => throw UnimplementedError();
@override
Future<void> close() async {}
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
final peerA = 'aa' * 32;
final peerB = 'bb' * 32;
late SocialService social;
setUp(() async {
social = await SocialService.fromRootSeedHex(seedHex);
LocaleSettings.setLocaleSync(AppLocale.en);
});
Widget app(SocialConnection connection, {ProfileCache? cache}) =>
TranslationProvider(
child: MaterialApp(
locale: AppLocale.en.flutterLocale,
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: YourPeopleScreen(
social: social,
connection: connection,
profileCache: cache,
),
),
);
SocialConnection online(TrustTransport trust) => SocialConnection(
social: social,
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => FakeSession(trust) as SocialSession,
);
testWidgets('offline shows the friendly note', (tester) async {
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
final connection = SocialConnection(social: social, settings: settings);
await tester.pumpWidget(app(connection));
await tester.pumpAndSettle();
expect(
find.text("You're offline — try again when you're connected"),
findsOneWidget,
);
});
testWidgets('lists who you vouch for and who vouches for you, with names',
(tester) async {
final trust = FakeTrustTransport(social.publicKeyHex)
..addCert(social.publicKeyHex, peerA) // you vouch for A
..addCert(peerB, social.publicKeyHex) // B vouches for you
..addCert(peerA, peerB); // unrelated edge, must not show
final cache = ProfileCache(InMemorySecretStore());
await cache.setName(peerA, 'Alice');
await tester.pumpWidget(app(online(trust), cache: cache));
await tester.pumpAndSettle();
expect(find.text('You vouch for'), findsOneWidget);
expect(find.text('They vouch for you'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget); // cached name wins
expect(find.text(shortPubkey(peerB)), findsOneWidget); // fallback
expect(find.byKey(Key('yourPeople.given.$peerA')), findsOneWidget);
expect(find.byKey(Key('yourPeople.received.$peerB')), findsOneWidget);
});
testWidgets('empty states show gentle notes', (tester) async {
await tester
.pumpWidget(app(online(FakeTrustTransport(social.publicKeyHex))));
await tester.pumpAndSettle();
expect(
find.textContaining("You don't vouch for anyone yet"),
findsOneWidget,
);
expect(find.text('No one vouches for you yet'), findsOneWidget);
});
testWidgets('revoking removes the vouch after confirming', (tester) async {
final trust = FakeTrustTransport(social.publicKeyHex)
..addCert(social.publicKeyHex, peerA);
await tester.pumpWidget(app(online(trust)));
await tester.pumpAndSettle();
await tester.tap(find.text('Stop vouching'));
await tester.pumpAndSettle();
expect(find.text('Stop vouching for this person?'), findsOneWidget);
await tester.tap(find.text('Stop vouching').last); // dialog action
await tester.pumpAndSettle();
expect(trust.certs, isEmpty);
expect(
find.textContaining("You don't vouch for anyone yet"),
findsOneWidget,
);
});
}