feat(trust): full Duniter web-of-trust membership (params + referents)

Slice 4 of Block 2. The pure rule was already parameterised; this adds the
policy, cold-start and UI around it.

- commons_core: WotParams (sigQty/stepMax/sigValidity, Duniter defaults) +
  WebOfTrust.membersWith; npubToHex helper (NIP-19 decode) for adding roots.
- WotSettings (keystore): the active parameters, configurable — a young
  network loosens them and tightens as it grows, as Ğ1 did. Defaults Duniter.
- TrustReferents: the bootstrap 'seeds' membership is measured from — a
  bundled asset (empty until real founders are curated, no invented keys)
  unioned with referents the user adds by npub/QR. Honest cold-start.
- TrustCubit: computes the full membership verdict against referents+params
  alongside the personal circle, and exposes a TrustTier (networkMember >
  inYourCircle > vouched > unknown). Certifications issued with the active
  validity (they expire and renew, Duniter rule).
- UI: chat trust badge by tier; a 'Network of trust' screen (manage roots +
  advanced params) reached from the profile. i18n en/es/pt/ast.

Tests: WotParams/membersWith, npubToHex, TrustReferents, WotSettings, and
TrustCubit tiers/membership. Resolves the WoT-parameters decision
(open-decisions §B). Trust net stays empty/undetermined until seeded — by
design; users bootstrap their own roots.
This commit is contained in:
vjrj 2026-07-10 21:01:11 +02:00
parent f767ccd70e
commit e715b129f6
29 changed files with 1111 additions and 39 deletions

View file

@ -0,0 +1,45 @@
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

@ -0,0 +1,28 @@
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

@ -91,6 +91,55 @@ 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),
peerPubkey: peer,
selfPubkey: me);
await vouched.load();
expect(vouched.state.tier, TrustTier.vouched);
await vouched.close();
final unknown = TrustCubit(FakeTrustTransport(me),
peerPubkey: 'nobody', selfPubkey: me);
await unknown.load();
expect(unknown.state.tier, TrustTier.unknown);
await unknown.close();
});
test('never vouches for self', () async {
final cubit =
TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me);