From 70905a0578e96cd6cb7441ade70746dd829333b5 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 02:44:37 +0200 Subject: [PATCH] feat(block2): wire the social identity into app_seeds (DI, no UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice: bridge Block 1's root seed to the Block 2 transport foundation. - SocialService: derives the secp256k1 Nostr identity from the SAME root seed the recovery QR backs up (no extra backup), exposes npub/pubkey, and opens a SocialSession = one shared connection carrying all three transports. - injector.dart: registers SocialService in get_it. Local-first — derivation is cheap + offline; no relay is contacted at startup. Replaces the old "nodeId slice of the seed is the social pubkey" placeholder; keeps the CRDT author id distinct (unifying it would rewrite authorship — a later decision). - Test: deterministic identity from the seed hex, matches commons_core, rejects malformed hex. 5 tests green; app_seeds analyzes clean (0 errors). --- apps/app_seeds/lib/di/injector.dart | 12 ++- .../lib/services/social_service.dart | 74 +++++++++++++++++++ .../test/services/social_service_test.dart | 48 ++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/app_seeds/lib/services/social_service.dart create mode 100644 apps/app_seeds/test/services/social_service_test.dart diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 29f26c5..855e86c 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -24,6 +24,7 @@ import '../services/ocr/tesseract_label_extractor.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; +import '../services/social_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep @@ -42,10 +43,16 @@ Future configureDependencies() async { openEncryptedExecutor(await _databaseFile(), dbKeyHex), ); - // Until real key derivation lands, the node/author id is a stable per-install - // slice of the root seed. It becomes the user's public key in the social layer. + // CRDT author id: a stable per-install slice of the root seed. Kept distinct + // from the social-layer public key on purpose — unifying the two would rewrite + // authorship of existing rows, a data-model decision for later. final nodeId = rootSeedHex.substring(0, 16); + // Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the + // SAME root seed, so it needs no extra backup. Cheap + offline; no relay is + // contacted here (local-first — the social layer only enriches). + final socialService = await SocialService.fromRootSeedHex(rootSeedHex); + // Seed the bundled species catalog (idempotent) before the UI opens. final speciesRepository = SpeciesRepository(database, idGen: IdGen()); await speciesRepository.seedBundled(await loadBundledSpecies()); @@ -75,6 +82,7 @@ Future configureDependencies() async { ..registerSingleton(fileService) ..registerSingleton(labelExtractor) ..registerSingleton(OnboardingStore(secretStore)) + ..registerSingleton(socialService) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart new file mode 100644 index 0000000..a2b1b43 --- /dev/null +++ b/apps/app_seeds/lib/services/social_service.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; + +/// The app-side entry point to the (Block 2) social layer. +/// +/// Holds the user's Nostr identity — deterministically derived from the same +/// root seed the recovery QR backs up (so it needs no extra backup) — and opens +/// social sessions on demand. Local-first: constructing this is cheap and +/// offline; nothing connects to a relay until [openSession] is called, so the +/// app runs fully without network and the social layer only enriches. +class SocialService { + SocialService({required this.identity, List relays = const []}) + : relays = List.unmodifiable(relays); + + /// The derived Nostr identity (secp256k1). Same key across reinstalls that + /// restore the same seed. + final NostrIdentity identity; + + /// Community relay URLs (may be empty — discovery/publishing degrade to + /// nothing when offline or unconfigured). + final List relays; + + /// Shareable public identity (`npub…`) and its hex form. + String get npub => identity.npub; + String get publicKeyHex => identity.publicKeyHex; + + /// Derives the identity from the stored root-seed hex (32-byte, 64 chars). + static Future fromRootSeedHex( + String rootSeedHex, { + List relays = const [], + }) async { + final identity = await NostrKeyDerivation.deriveFromSeed( + _hexToBytes(rootSeedHex), + ); + return SocialService(identity: identity, relays: relays); + } + + /// Opens a session against [relayUrl]: one shared connection carrying all + /// three transports (offers, messaging, trust). Caller closes it. + Future openSession(String relayUrl) async { + final connection = + await NostrConnection.connect(relayUrl, identity: identity); + return SocialSession(connection); + } +} + +/// One live relay connection with the three transports on top — the +/// "one connection, three interfaces" shape, at the app layer. +class SocialSession { + SocialSession(this._connection) + : offers = NostrOfferTransport(_connection), + messages = NostrMessageTransport(_connection), + trust = NostrTrustTransport(_connection); + + final NostrConnection _connection; + + final OfferTransport offers; + final MessageTransport messages; + final TrustTransport trust; + + Future close() => _connection.close(); +} + +Uint8List _hexToBytes(String hex) { + if (hex.length.isOdd) { + throw ArgumentError('hex string must have an even length'); + } + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} diff --git a/apps/app_seeds/test/services/social_service_test.dart b/apps/app_seeds/test/services/social_service_test.dart new file mode 100644 index 0000000..7f8fd68 --- /dev/null +++ b/apps/app_seeds/test/services/social_service_test.dart @@ -0,0 +1,48 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/social_service.dart'; + +/// Wiring test: the app derives its social identity from the SAME root seed the +/// recovery QR backs up, deterministically and without any network. +void main() { + // A fixed 32-byte seed (64 hex chars). + const seedHex = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + + test('derives a stable Nostr identity from the root-seed hex', () async { + final service = await SocialService.fromRootSeedHex(seedHex); + expect(service.npub, startsWith('npub1')); + expect(service.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + + test('is deterministic: same seed → same public identity', () async { + final a = await SocialService.fromRootSeedHex(seedHex); + final b = await SocialService.fromRootSeedHex(seedHex); + expect(a.publicKeyHex, b.publicKeyHex); + expect(a.npub, b.npub); + }); + + test('matches commons_core derivation on the same bytes', () async { + final service = await SocialService.fromRootSeedHex(seedHex); + final direct = await NostrKeyDerivation.deriveFromSeed([ + for (var i = 0; i < seedHex.length; i += 2) + int.parse(seedHex.substring(i, i + 2), radix: 16), + ]); + expect(service.publicKeyHex, direct.publicKeyHex); + }); + + test('a different seed yields a different identity', () async { + final a = await SocialService.fromRootSeedHex(seedHex); + final b = await SocialService.fromRootSeedHex( + 'ff${seedHex.substring(2)}', + ); + expect(a.publicKeyHex, isNot(b.publicKeyHex)); + }); + + test('rejects a malformed (odd-length) seed hex', () async { + await expectLater( + SocialService.fromRootSeedHex('abc'), + throwsA(isA()), + ); + }); +}