From ff255d366f61bc488bc1aa4608950f3683cc41b1 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 11 Jul 2026 22:07:17 -0400 Subject: [PATCH] feat(#174): promote name-only block to pubkey (advert/DM) + 30d age-out BlockService.maybePromote(name,key): when a name-only block links to a pubkey, add the key to blockedKeys and drop the name entry. Connector calls it on every contact advert AND incoming DM (the two ways we learn a name<->key link). _pruneExpiredNames on load drops name-only blocks older than 30 days. Epic A #165 / A7 #174. Design: docs/architecture/block-contract-as-built.md. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 15 +++++++++++++ lib/services/block_service.dart | 32 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index e3c8938..7cfb7be 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4797,6 +4797,12 @@ class MeshCoreConnector extends ChangeNotifier { return; } + // Learned name<->key from this advert: promote a matching name-only block. + final blockService = _blockService; + if (blockService != null) { + unawaited(blockService.maybePromote(contact.name, contact.publicKeyHex)); + } + if (contact.type == advTypeRepeater) { final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) @@ -5051,6 +5057,15 @@ class MeshCoreConnector extends ChangeNotifier { if (contact != null) { _updateContactLastMessageAt(contact.publicKeyHex, message.timestamp); } + if (contact != null) { + // Learned name<->key from this DM: promote a matching name-only block. + final blockService = _blockService; + if (blockService != null) { + unawaited( + blockService.maybePromote(contact.name, contact.publicKeyHex), + ); + } + } if (!message.isOutgoing) { final existing = _conversations[message.senderKeyHex]; final incomingTimestamp = message.timestamp.millisecondsSinceEpoch; diff --git a/lib/services/block_service.dart b/lib/services/block_service.dart index f3df396..78920e4 100644 --- a/lib/services/block_service.dart +++ b/lib/services/block_service.dart @@ -24,6 +24,7 @@ class BlockService extends ChangeNotifier { _blockedNames ..clear() ..addAll(await _store.loadNames()); + await _pruneExpiredNames(); notifyListeners(); } @@ -61,4 +62,35 @@ class BlockService extends ChangeNotifier { await _store.saveNames(_blockedNames); notifyListeners(); } + + /// Name-only blocks older than this are pruned on load (self-cleaning). + static const Duration _nameBlockTtl = Duration(days: 30); + + /// Promote a name-only block to a durable pubkey block once we learn the + /// identity behind it (observed via an advert or a DM). No-op if the name + /// isn't name-blocked. + Future maybePromote(String name, String publicKeyHex) async { + final n = name.trim().toLowerCase(); + if (n.isEmpty || !_blockedNames.containsKey(n)) return; + _blockedNames.remove(n); + _blockedKeys.add(publicKeyHex.toLowerCase()); + await _store.saveNames(_blockedNames); + await _store.saveKeys(_blockedKeys); + notifyListeners(); + } + + /// Drop name-only blocks that never linked to a pubkey within [_nameBlockTtl]. + Future _pruneExpiredNames() async { + final cutoff = + DateTime.now().millisecondsSinceEpoch - _nameBlockTtl.inMilliseconds; + final expired = _blockedNames.entries + .where((e) => e.value < cutoff) + .map((e) => e.key) + .toList(); + if (expired.isEmpty) return; + for (final n in expired) { + _blockedNames.remove(n); + } + await _store.saveNames(_blockedNames); + } }