diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae5c6b0 --- /dev/null +++ b/README.md @@ -0,0 +1,267 @@ +# swarm + +Actor-based business logic library. Each actor runs in an isolated Dart +isolate; you send it a message and await a result. Actors are created with +a factory tear-off so the handler is constructed inside the isolate: + +```dart +final actor = Actor.create(MyActor.new); +final result = await actor.send(MyMessage()); +await actor.stop(); +``` + +All examples below call `handle()` directly (no isolate), which is how the +unit tests work. In production code use `Actor.create` + `send`. + +--- + +## FileActor + +Local file I/O — text and binary. + +```dart +final actor = FileActor(); +``` + +No constructor parameters. + +| Message | Result | Description | +|---|---|---| +| `LoadFile(path)` | `FileLoaded(content)` | Read file as UTF-8 string | +| `SaveFile(path, content)` | `FileSaved()` | Write string to file | +| `LoadBinaryFile(path)` | `BinaryFileLoaded(bytes)` | Read file as `Uint8List` | +| `SaveBinaryFile(path, bytes)` | `FileSaved()` (same type) | Write `Uint8List` to file | + +```dart +final actor = FileActor(); + +// Text +await actor.handle(SaveFile('/tmp/note.txt', 'hello')); +final r = await actor.handle(LoadFile('/tmp/note.txt')); +print((r as FileLoaded).content); // hello + +// Binary +await actor.handle(SaveBinaryFile('/tmp/data.bin', bytes)); +final rb = await actor.handle(LoadBinaryFile('/tmp/data.bin')); +final loaded = (rb as BinaryFileLoaded).bytes; +``` + +--- + +## NostrSignerActor + +Signs Nostr events (NIP-01) using a secp256k1 private key. + +```dart +final actor = NostrSignerActor(privateKeyHex); +``` + +| Parameter | Type | Description | +|---|---|---| +| `privateKey` | `String` | 64-char hex private key | + +| Message | Result | Description | +|---|---|---| +| `SignEvent(kind, content, tags?, createdAt?)` | `EventSigned(event)` | Returns a signed event as a JSON string with all seven NIP-01 fields | +| `GetPublicKey()` | `PublicKeyResult(publicKey)` | Returns the hex public key for this signer | + +`tags` defaults to `[]`. `createdAt` defaults to `DateTime.now()` (Unix seconds). + +```dart +final signer = NostrSignerActor(privkeyHex); + +final pk = await signer.handle(const GetPublicKey()); +print((pk as PublicKeyResult).publicKey); + +final signed = await signer.handle( + const SignEvent(kind: 1, content: 'hello world'), +); +final eventJson = (signed as EventSigned).event; // ready to publish +``` + +--- + +## NostrPublishActor + +Publishes signed Nostr events to one or more relays and collects OK/rejection +outcomes. Retries failed relay connections with exponential back-off. + +```dart +final actor = NostrPublishActor( + relayUrls: ['wss://relay.example.com'], + retryConfig: const RetryConfig(), // optional + batchTimeout: const Duration(seconds: 10), // optional +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `relayUrls` | `List` | required | Relay WebSocket URLs | +| `retryConfig` | `RetryConfig` | see below | Retry behaviour | +| `batchTimeout` | `Duration` | 10 s | How long to wait for OK per relay | + +**RetryConfig** + +| Field | Default | Description | +|---|---|---| +| `maxAttempts` | 3 | Max connection attempts per relay | +| `baseDelay` | 1 s | Initial retry delay | +| `maxDelay` | 32 s | Cap on retry delay | +| `jitter` | `true` | Add ±25 % randomisation to delay | + +| Message | Result | Description | +|---|---|---| +| `PublishBatch(events)` | `BatchPublished(outcomes)` | Publishes a list of JSON event strings; `outcomes` maps relay URL → `RelayConfirmed`, `RelayRejected(reason)`, or `RelayFailed` | + +Events must be fully signed JSON strings (e.g. from `NostrSignerActor`). + +```dart +final signer = NostrSignerActor(privkeyHex); +final publisher = NostrPublishActor( + relayUrls: ['wss://relay.otherwhere.app/'], +); + +final signed = await signer.handle( + const SignEvent(kind: 1, content: 'hello'), +); +final result = await publisher.handle( + PublishBatch([(signed as EventSigned).event]), +); +final outcomes = (result as BatchPublished).outcomes; +for (final entry in outcomes.entries) { + print('${entry.key}: ${entry.value}'); + // wss://relay.otherwhere.app/: RelayConfirmed +} +``` + +--- + +## NostrFetchActor + +Fetches Nostr events from one or more relays using a NIP-01 filter. Collects +events until EOSE or a deadline, deduplicates by event ID. + +```dart +final actor = NostrFetchActor( + relayUrls: ['wss://relay.example.com'], + eoseDeadline: const Duration(seconds: 5), // optional +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `relayUrls` | `List` | required | Relay WebSocket URLs | +| `eoseDeadline` | `Duration` | 5 s | Max wait per relay for EOSE | + +| Message | Result | Description | +|---|---|---| +| `FetchEvents(filter)` | `EventsFetched(events)` | `filter` is a JSON string (NIP-01 filter object); `events` is a deduplicated list of JSON event strings | + +```dart +final fetcher = NostrFetchActor( + relayUrls: ['wss://relay.otherwhere.app/'], +); + +final result = await fetcher.handle( + FetchEvents('{"kinds":[1],"authors":["$pubkeyHex"],"limit":10}'), +); +final events = (result as EventsFetched).events; +for (final e in events) { + print(jsonDecode(e)['content']); +} +``` + +--- + +## GeoActor + +Geohash encoding, decoding, and neighbour lookup via the +[dart_geohash](https://pub.dev/packages/dart_geohash) library. + +```dart +final actor = GeoActor(); +``` + +No constructor parameters. + +| Message | Result | Description | +|---|---|---| +| `EncodeLocation(latitude, longitude, precision?)` | `GeohashEncoded(geohash)` | Encode lat/lon to geohash string; `precision` defaults to 9 | +| `DecodeGeohash(geohash)` | `LocationDecoded(latitude, longitude)` | Decode geohash to coordinates | +| `NeighborsOf(geohash)` | `NeighborsResult(neighbors)` | Returns a `Map` with keys `NORTH`, `NORTHEAST`, `EAST`, `SOUTHEAST`, `SOUTH`, `SOUTHWEST`, `WEST`, `NORTHWEST`, `CENTRAL` | + +```dart +final geo = GeoActor(); + +final enc = await geo.handle( + const EncodeLocation(latitude: 48.8566, longitude: 2.3522, precision: 7), +); +print((enc as GeohashEncoded).geohash); // u09tunq + +final dec = await geo.handle(DecodeGeohash('u09tunq')); +print((dec as LocationDecoded).latitude); // ~48.856 +print((dec as LocationDecoded).longitude); // ~2.352 + +final nb = await geo.handle(NeighborsOf('u09tunq')); +print((nb as NeighborsResult).neighbors['NORTH']); // u09tunu +``` + +--- + +## FileUploadActor + +Uploads files to a Nostr-authenticated remote storage service. Handles +NIP-44 encrypted registration (kind 5392) and NIP-98 signed upload requests +(kind 27235). Returns a permanent public CDN URL. + +```dart +final actor = FileUploadActor( + privateKey: userPrivkeyHex, + appName: kAppName, + appSecret: kAppSecret, +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `privateKey` | `String` | required | User's 64-char hex Nostr private key | +| `appName` | `String` | required | App identifier registered with the service | +| `appSecret` | `String` | required | Per-app shared secret (keep out of public source) | +| `apiBaseUrl` | `String` | `https://upload.otherwhere.app` | API base URL | +| `relayUrl` | `String` | `wss://relay.otherwhere.app/` | Nostr relay for registration events | + +Call `actor.close()` when done to release HTTP connections. + +| Message | Result | Description | +|---|---|---| +| `RegisterForUpload()` | `UploaderRegistered()` | Publishes a kind 5392 event to the relay with the NIP-44 encrypted app secret. Call once per user; re-registering is safe (idempotent server-side). | +| `UploadFile(bytes, contentType)` | `FileUploaded(cdnUrl)` | Requests a presigned PUT URL, uploads the file directly to storage, and returns the permanent CDN URL. Throws if `bytes.length > 10 MB`. Accepted content types: `image/jpeg`, `image/png`, `image/webp`, `image/gif`. | + +**Registration** should be called once and the result persisted (e.g. in +`flutter_secure_storage`). If the flag is lost, calling it again is harmless. +Do not block the upload flow on registration — unregistered users get a small +daily upload allowance while the registration event propagates. + +```dart +const storage = FlutterSecureStorage(); +const _kRegisteredKey = 'upload_registered_$kAppName'; + +final actor = FileUploadActor( + privateKey: await storage.read(key: 'nostr_privkey'), + appName: kAppName, + appSecret: kAppSecret, +); + +// Register once +if (await storage.read(key: _kRegisteredKey) != 'true') { + await actor.handle(const RegisterForUpload()); + await storage.write(key: _kRegisteredKey, value: 'true'); +} + +// Upload +final result = await actor.handle( + UploadFile(bytes: imageBytes, contentType: 'image/jpeg'), +); +final url = (result as FileUploaded).cdnUrl; +// https://cdn.otherwhere.app///.jpg +```