feat(block2): Nostr transport backend in commons_core (all 3 contracts)
Second production slice: the concrete Nostr backend on the shared connection, using the vetted nostr package's crypto (NIP-44 spec-vector tested, gift wrap). - NostrConnection: one dart:io WebSocket + identity + EVENT/OK, REQ/EOSE via the library's Event/Filter/Request. Pure Dart, no Flutter. - NostrOfferTransport (NIP-99 kind 30402) + Nip99Codec: geohash coarsened to a NIP-52 prefix ladder on the wire; Offer stays inventory/location-agnostic. - NostrMessageTransport (NIP-17): send/inbox via the library's DirectMessage gift-wrap onion. - NostrTrustTransport (custom addressable kind 30777): certify/revoke/discover. - MiniRelay test support + integration tests: offers, messaging (metadata privacy), WoT membership + revoke, and trust-filters-offers — all over ONE shared connection, hermetic in-process relay. Renamed the core offer lifecycle enum OfferStatus -> OfferLifecycle to avoid colliding with app_seeds' sharing-intent OfferStatus (no Block 1 code touched; app_seeds analyzes clean, 0 errors). commons_core: 55 tests green.
This commit is contained in:
parent
1253f0c632
commit
f50a4737cb
10 changed files with 791 additions and 3 deletions
131
packages/commons_core/test/support/mini_relay.dart
Normal file
131
packages/commons_core/test/support/mini_relay.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:nostr/nostr.dart';
|
||||
|
||||
/// A tiny in-process Nostr relay (NIP-01 subset) for tests: EVENT / REQ / EOSE /
|
||||
/// CLOSE, filtering by `kinds` / `authors` / `ids` / `#x` tags, with addressable
|
||||
/// (30000–39999) replacement by (kind, pubkey, `d`). Hermetic — no network — so
|
||||
/// transport tests are CI-safe. Not a real relay.
|
||||
class MiniRelay {
|
||||
MiniRelay._(this._server);
|
||||
|
||||
final HttpServer _server;
|
||||
final List<Map<String, dynamic>> _events = [];
|
||||
final Set<_Sub> _subs = {};
|
||||
|
||||
int get port => _server.port;
|
||||
String get url => 'ws://127.0.0.1:$port';
|
||||
int get storedCount => _events.length;
|
||||
|
||||
/// All stored events of a given [kind] (for eavesdropper assertions).
|
||||
List<Map<String, dynamic>> eventsOfKind(int kind) =>
|
||||
_events.where((e) => e['kind'] == kind).toList();
|
||||
|
||||
static Future<MiniRelay> start() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final relay = MiniRelay._(server);
|
||||
relay._accept();
|
||||
return relay;
|
||||
}
|
||||
|
||||
void _accept() {
|
||||
_server.listen((req) async {
|
||||
if (!WebSocketTransformer.isUpgradeRequest(req)) {
|
||||
req.response.statusCode = HttpStatus.badRequest;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final ws = await WebSocketTransformer.upgrade(req);
|
||||
final socketSubs = <_Sub>{};
|
||||
ws.listen(
|
||||
(data) => _onMessage(ws, socketSubs, data as String),
|
||||
onDone: () => _subs.removeAll(socketSubs),
|
||||
onError: (_) => _subs.removeAll(socketSubs),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onMessage(WebSocket ws, Set<_Sub> socketSubs, String data) {
|
||||
final msg = jsonDecode(data) as List;
|
||||
switch (msg[0]) {
|
||||
case 'EVENT':
|
||||
_handleEvent(ws, (msg[1] as Map).cast<String, dynamic>());
|
||||
case 'REQ':
|
||||
final subId = msg[1] as String;
|
||||
final filters = msg.sublist(2).cast<Map<String, dynamic>>();
|
||||
final sub = _Sub(ws, subId, filters);
|
||||
_subs.add(sub);
|
||||
socketSubs.add(sub);
|
||||
for (final e in _events) {
|
||||
if (sub.matches(e)) _send(ws, ['EVENT', subId, e]);
|
||||
}
|
||||
_send(ws, ['EOSE', subId]);
|
||||
case 'CLOSE':
|
||||
final subId = msg[1] as String;
|
||||
socketSubs.removeWhere((s) => s.id == subId);
|
||||
_subs.removeWhere((s) => s.ws == ws && s.id == subId);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(WebSocket ws, Map<String, dynamic> event) {
|
||||
final id = event['id'] as String;
|
||||
try {
|
||||
Event.fromMap(event, verify: true); // reject bad signatures like a relay
|
||||
} catch (_) {
|
||||
_send(ws, ['OK', id, false, 'invalid: bad signature']);
|
||||
return;
|
||||
}
|
||||
final kind = event['kind'] as int;
|
||||
if (kind >= 30000 && kind < 40000) {
|
||||
final d = _tag(event, 'd') ?? '';
|
||||
_events.removeWhere((e) =>
|
||||
e['kind'] == kind && e['pubkey'] == event['pubkey'] && (_tag(e, 'd') ?? '') == d);
|
||||
}
|
||||
_events.add(event);
|
||||
_send(ws, ['OK', id, true, '']);
|
||||
for (final sub in _subs) {
|
||||
if (sub.matches(event)) _send(sub.ws, ['EVENT', sub.id, event]);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(WebSocket ws, Object message) => ws.add(jsonEncode(message));
|
||||
|
||||
Future<void> stop() => _server.close(force: true);
|
||||
}
|
||||
|
||||
String? _tag(Map<String, dynamic> event, String name) {
|
||||
for (final t in (event['tags'] as List)) {
|
||||
final row = (t as List).cast<String>();
|
||||
if (row.isNotEmpty && row[0] == name && row.length > 1) return row[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class _Sub {
|
||||
_Sub(this.ws, this.id, this.filters);
|
||||
|
||||
final WebSocket ws;
|
||||
final String id;
|
||||
final List<Map<String, dynamic>> filters;
|
||||
|
||||
bool matches(Map<String, dynamic> e) => filters.any((f) => _matches(e, f));
|
||||
|
||||
bool _matches(Map<String, dynamic> e, Map<String, dynamic> f) {
|
||||
if (f['kinds'] != null && !(f['kinds'] as List).contains(e['kind'])) return false;
|
||||
if (f['authors'] != null && !(f['authors'] as List).contains(e['pubkey'])) return false;
|
||||
if (f['ids'] != null && !(f['ids'] as List).contains(e['id'])) return false;
|
||||
for (final entry in f.entries) {
|
||||
if (!entry.key.startsWith('#')) continue;
|
||||
final tagName = entry.key.substring(1);
|
||||
final wanted = (entry.value as List).cast<String>();
|
||||
final present = (e['tags'] as List)
|
||||
.map((t) => (t as List).cast<String>())
|
||||
.where((t) => t.isNotEmpty && t[0] == tagName && t.length > 1)
|
||||
.map((t) => t[1]);
|
||||
if (!wanted.any(present.contains)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue