feat(sync): encrypted device-to-device sync transport (core foundation)

The most foundational Block-2+ gap: the data model was built CRDT-ready
(HLC, tombstones, LWW, append-only Movement) and the import reconciler
proves the merge — but nothing replicated a device's inventory to your
OTHER devices. Only manual file backup/restore moved data between devices.

This lands the transport foundation in commons_core (seed-agnostic, on the
shared connection — the 'one connection, N interfaces' shape):

- SyncTransport: moves an opaque, encrypted snapshot blob between ONE
  identity's own devices. The core carries bytes + does the crypto; the app
  decides what the bytes are and how to merge them.
- NostrSyncTransport: NIP-78 app-data (kind 30078, addressable/replaceable
  per device via the d-tag), content NIP-44-encrypted TO SELF, discovery
  filtered to your own author key. Each device keeps one replaceable record.

Tests (MiniRelay, pure Dart): a snapshot replicates to the identity's other
device; the relay only ever sees ciphertext; another identity neither
receives nor could decrypt; re-push replaces; two devices keep their own.

Confirms the open-decisions §D.4 guarantee (sync leaks neither the key nor
plaintext). NEXT slice (app): a SyncService that serializes the inventory
(reusing ExportImportService), pushes on mutation, and runs the existing
idempotent importInventory on receive; a stable per-install device id;
wire sync into SocialSession.
This commit is contained in:
vjrj 2026-07-10 23:16:07 +02:00
parent bb4ee2fd89
commit 182c334883
5 changed files with 262 additions and 1 deletions

View file

@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
import '../support/mini_relay.dart';
/// Device-to-device sync (NIP-78 app data, NIP-44 encrypted to self) over a
/// hermetic in-process relay: one identity's snapshot replicates to its OTHER
/// device, encrypted end-to-end, with each device replacing only its own record.
void main() {
const ns = 'org.comunes.tane/inventory';
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
Uint8List(32)..fillRange(0, 32, fill),
);
Future<NostrSyncTransport> deviceFor(NostrIdentity id, String deviceId) async =>
NostrSyncTransport(
await NostrConnection.connect(relay.url, identity: id),
namespace: ns,
deviceId: deviceId,
);
test('a snapshot replicates to the same identity\'s other device', () async {
final me = await idFor(7);
final deviceA = await deviceFor(me, 'A');
final deviceB = await deviceFor(me, 'B');
final payload = utf8.encode('{"varieties":[{"id":"v1","label":"Tomate"}]}');
await deviceA.pushSnapshot(payload);
final got = await deviceB.snapshotsUntilEose();
expect(got, hasLength(1));
expect(got.single.deviceId, 'A');
expect(got.single.data, payload);
await deviceA.close();
await deviceB.close();
});
test('the relay only ever sees ciphertext, never the snapshot', () async {
final me = await idFor(7);
final deviceA = await deviceFor(me, 'A');
final payload = utf8.encode('secret inventory');
await deviceA.pushSnapshot(payload);
final stored = relay.eventsOfKind(NostrSyncTransport.kindAppData);
expect(stored, hasLength(1));
final content = stored.single['content'] as String;
expect(content, isNot(equals(base64.encode(payload))));
expect(content, isNot(contains('secret inventory')));
await deviceA.close();
});
test('another identity neither receives nor could read it', () async {
final me = await idFor(7);
final stranger = await idFor(9);
final deviceA = await deviceFor(me, 'A');
final strangerDevice = await deviceFor(stranger, 'A');
await deviceA.pushSnapshot(utf8.encode('mine only'));
// Filtered to the stranger's own author key → nothing of mine arrives.
expect(await strangerDevice.snapshotsUntilEose(), isEmpty);
await deviceA.close();
await strangerDevice.close();
});
test('re-pushing replaces this device\'s record (one snapshot per device)',
() async {
final me = await idFor(7);
final deviceA = await deviceFor(me, 'A');
final deviceB = await deviceFor(me, 'B');
await deviceA.pushSnapshot(utf8.encode('v1'));
await deviceA.pushSnapshot(utf8.encode('v2'));
final got = await deviceB.snapshotsUntilEose();
expect(got, hasLength(1));
expect(utf8.decode(got.single.data), 'v2');
await deviceA.close();
await deviceB.close();
});
test('two devices each keep their own snapshot', () async {
final me = await idFor(7);
final deviceA = await deviceFor(me, 'A');
final deviceB = await deviceFor(me, 'B');
final reader = await deviceFor(me, 'C');
await deviceA.pushSnapshot(utf8.encode('from A'));
await deviceB.pushSnapshot(utf8.encode('from B'));
final got = await reader.snapshotsUntilEose();
expect(got, hasLength(2));
expect(
{for (final s in got) s.deviceId: utf8.decode(s.data)},
{'A': 'from A', 'B': 'from B'},
);
await deviceA.close();
await deviceB.close();
await reader.close();
});
}