diff --git a/.doccheck-allow b/.doccheck-allow new file mode 100644 index 00000000..ee41e7f5 --- /dev/null +++ b/.doccheck-allow @@ -0,0 +1,56 @@ +# Names in docs that are not types in this repository. Checked by `doccheck`. +# +# Most of these appear in docs/developer/SIMPLIFICATION_PROPOSAL.md, which +# proposes reducing nostr-java from 9 modules and ~170 classes to 4 modules and +# ~40. The names below are that proposal's vocabulary, plus types named in +# problem write-ups. They are not shipped classes; remove a name once it exists. + +# Proposed interfaces and types +IElement +IEvent +IGenericElement +IKey +ITag +IBech32Encodable +BaseEvent +ElementAttribute +TagRegistry + +# Proposed tag and filter types +AddressTag +AddressTagFilter +AuthorFilter +GenericTagQueryFilter +HashtagTagFilter +IdentifierTag +KindFilter +PubKeyTag +SinceFilter +UntilFilter + +# Proposed event and serialization types +GenericEventDeserializer +GenericEventSerializer +GenericMessage +ReactionEvent +TextNoteEvent + +# Proposed transport and client types +DefaultHttpClientProvider +NostrWebSocketClient +SpringNostrWebSocketClient +ReconnectingWebSocketClient +ReconnectPolicy +RelayAuthHandler +RelayConnectionBroker +SubscriptionBuffers +WebSocketClientConfig + +# MCP server proposal (docs/explanation/nostr-java-mcp-spec.md) +McpServer +StdioServerTransportProvider +HttpServletStreamableServerTransportProvider + +# NIP references written in type case +NIP01 +NIP61 diff --git a/.scratch/nostr-java-api/issues/01-extract-relay-connection-seam.md b/.scratch/nostr-java-api/issues/01-extract-relay-connection-seam.md new file mode 100644 index 00000000..23db6144 --- /dev/null +++ b/.scratch/nostr-java-api/issues/01-extract-relay-connection-seam.md @@ -0,0 +1,31 @@ +# 01: Extract the `RelayConnection` seam + +**What to build:** A contributor can write a test that drives relay behaviour, accepting an +event, rejecting it with a reason, going silent, or dropping mid-stream, without opening a +WebSocket, running Docker, or mocking a `WebSocketSession`. + +This is a prefactor: no user-visible behaviour changes. It exists because every later ticket +needs a place to substitute relay behaviour, and today the only way in is a package-private +constructor taking a mocked session, which is unreachable from other modules and does not scale +to several relays failing independently. + +A narrow `RelayConnection` interface is extracted in `nostr-java-client`, exposing only what a +relay pool needs: connect, send, subscribe, connection state, close. It deliberately does not +mirror all of `NostrRelayClient`'s public surface. `NostrRelayClient` becomes its production +implementation, and a `RelayConnectionFactory` maps a relay URI to a connection. + +**Blocked by:** None (can start immediately). + +**Status:** done + +- [x] `RelayConnection` exposes connect, send, subscribe, connection state, and close, and + nothing that only `NostrRelayClient` needs +- [x] `NostrRelayClient` implements `RelayConnection` with no change to its existing behaviour +- [x] `RelayConnectionFactory` resolves a relay URI to a `RelayConnection` +- [x] A `FakeRelay` test fixture implements `RelayConnection` and can be scripted to accept, + reject with a verbatim reason, never respond, drop mid-stream, emit a given event + sequence, and delay or withhold `EOSE` +- [x] At least one existing client test scenario is reproduced against `FakeRelay` with no + Mockito, demonstrating the fixture is sufficient +- [x] The interface carries no Spring types, so modules above can depend on it without Spring +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/02-relay-pool-fan-out-publishing.md b/.scratch/nostr-java-api/issues/02-relay-pool-fan-out-publishing.md new file mode 100644 index 00000000..4b20a602 --- /dev/null +++ b/.scratch/nostr-java-api/issues/02-relay-pool-fan-out-publishing.md @@ -0,0 +1,33 @@ +# 02: `RelayPool` with fan-out publishing and `PublishResult` + +**What to build:** A developer names several relays and publishes one event to all of them in a +single call, then inspects exactly what each relay did with it. + +Publishing to five relays plausibly yields three acceptances, one rejection with a reason such +as `blocked: pubkey banned`, and one timeout. That partial outcome is ordinary data the caller +reads from a `PublishResult`, not an exception. Total failure, where no relay accepted at all, +throws: this is the lesson v2.0.0 learned when silent empty-list timeout returns were replaced +by `RelayTimeoutException`. + +The pool is best-effort on construction. A client with five configured relays, two of them +unreachable, still starts and publishes to the other three. + +See ADR-0002 for the failure semantics and `docs/CONTEXT.md` for the vocabulary. + +**Blocked by:** 01 (Extract the `RelayConnection` seam). + +**Status:** done + +- [x] `RelayPool` is constructed from relay URIs via `RelayConnectionFactory` and lives in + `nostr-java-client` +- [x] Publishing fans out concurrently across relays on Virtual Threads +- [x] `PublishResult` records, per relay, accepted, rejected with the relay's verbatim reason, + or timed out +- [x] Publishing waits for every relay's `OK` up to a pool-level timeout; relays that miss it + are recorded as timed out rather than left unresolved +- [x] Publishing throws when zero relays accepted the event +- [x] Publishing returns normally when at least one relay accepted, however many failed +- [x] Construction succeeds when some relays are unreachable, marking them down +- [x] Tests cover three-accept/one-reject/one-timeout, zero acceptances throwing, and + construction with an unreachable member, all against `FakeRelay` +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/03-per-relay-serialization-and-health.md b/.scratch/nostr-java-api/issues/03-per-relay-serialization-and-health.md new file mode 100644 index 00000000..84b6f17a --- /dev/null +++ b/.scratch/nostr-java-api/issues/03-per-relay-serialization-and-health.md @@ -0,0 +1,30 @@ +# 03: Per-relay serialization and connection health + +**What to build:** A developer publishes from several threads at once without hitting +`IllegalStateException: A request is already in flight`, and can see which relays are actually +carrying traffic. + +`NostrRelayClient` permits one request in flight per connection. The pool therefore serializes +operations per relay: concurrent publishes to the same relay queue behind a lock, while +fan-out across different relays stays concurrent. A slow relay delays only its own queue. + +This is a known throughput ceiling, not a bug to be worked around by opening more sockets per +relay, which relays penalise. Making `NostrRelayClient` multiplex is separate follow-up work. + +An operator can observe per-relay `ConnectionState`, and relays that are down are retried in +the background so they rejoin without an application restart. + +See ADR-0004. + +**Blocked by:** 02 (`RelayPool` with fan-out publishing). + +**Status:** done + +- [x] Concurrent publishes to one relay queue rather than throwing +- [x] Publishes to different relays still proceed concurrently +- [x] Per-relay `ConnectionState` is observable from the pool +- [x] Relays that are down are retried in the background and rejoin the pool when they recover +- [x] A relay that recovers participates in subsequent publishes without a restart +- [x] Tests cover concurrent publishes to one relay, concurrency preserved across relays, and + a down relay rejoining +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/04-fan-in-subscriptions-with-dedup.md b/.scratch/nostr-java-api/issues/04-fan-in-subscriptions-with-dedup.md new file mode 100644 index 00000000..23e2fabc --- /dev/null +++ b/.scratch/nostr-java-api/issues/04-fan-in-subscriptions-with-dedup.md @@ -0,0 +1,32 @@ +# 04: Fan-in subscriptions with de-duplication + +**What to build:** A developer opens one subscription covering every relay in the pool and +receives each matching event exactly once, already parsed. + +The same event arrives from every relay that has it, so without de-duplication a five-relay +subscription shows every note five times. The pool de-duplicates by event `id` through a +bounded LRU window: an unbounded set would leak memory on precisely the long-lived firehose +subscriptions that need de-duplication most. + +Callbacks receive `GenericEvent` rather than raw JSON. This is capability, not convenience: +de-duplicating by event id forces the pool to parse inbound payloads anyway, so handing back +the undecoded string would be perverse. + +Closing the subscription handle unsubscribes from every relay at once. + +See ADR-0002 and ADR-0004. + +**Blocked by:** 02 (`RelayPool` with fan-out publishing). + +**Status:** done + +- [x] One subscribe call registers the filter with every relay in the pool +- [x] Callbacks receive parsed `GenericEvent` values +- [x] An event arriving from several relays is delivered to the caller once +- [x] De-duplication uses a bounded LRU window whose size is configurable, defaulting to the + low thousands +- [x] Memory does not grow without bound over a long-lived subscription +- [x] Closing the subscription handle stops delivery from every relay +- [x] Tests cover a duplicate event from four relays delivered once, window eviction staying + bounded, and closing the handle stopping all relays +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/05-synthetic-eose-and-mid-stream-recovery.md b/.scratch/nostr-java-api/issues/05-synthetic-eose-and-mid-stream-recovery.md new file mode 100644 index 00000000..aa7944ac --- /dev/null +++ b/.scratch/nostr-java-api/issues/05-synthetic-eose-and-mid-stream-recovery.md @@ -0,0 +1,32 @@ +# 05: Synthetic EOSE and mid-stream recovery + +**What to build:** A developer can tell when the stored-event backlog is drained across all +relays, and a long-lived subscription repairs itself instead of quietly degrading. + +A REQ returns stored events, then `EOSE`, then live events. Across five relays that is five +separate `EOSE` frames, but an application needs one answer to "is the backlog drained" to hide +a loading spinner. The pool aggregates them into a single synthetic EOSE, emitted once every +participating relay has reported or a timeout expires. The timeout matters: one dead relay must +not leave a UI loading forever. + +The characteristic multi-relay failure is a firehose that degrades from five relays to one over +a day while the caller is never told. So a relay dropping mid-stream notifies an error callback +and, when that relay reconnects, is re-subscribed from the stored filter. Subscriptions are +therefore stateful objects holding their filter, not fire-and-forget handles. + +See ADR-0004 and ADR-0005. + +**Blocked by:** 04 (Fan-in subscriptions with de-duplication). + +**Status:** done + +- [x] One synthetic end-of-stored-events signal is emitted after every participating relay has + sent its own `EOSE` +- [x] The synthetic signal is still emitted when a relay never responds, bounded by a timeout +- [x] Per-relay `EOSE` frames are not exposed to callers +- [x] A relay dropping mid-stream notifies the caller's error callback +- [x] A dropped relay is automatically re-subscribed from the stored filter on reconnect +- [x] A malformed relay payload is reported without ending the subscription +- [x] Tests cover EOSE after all relays report, EOSE despite one silent relay, drop-notify- + resubscribe, and a malformed payload not killing the stream +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/06-runtime-pool-membership.md b/.scratch/nostr-java-api/issues/06-runtime-pool-membership.md new file mode 100644 index 00000000..a064edd0 --- /dev/null +++ b/.scratch/nostr-java-api/issues/06-runtime-pool-membership.md @@ -0,0 +1,28 @@ +# 06: Runtime pool membership + +**What to build:** A developer adds and removes relays while the client is running, so the +relay set can follow user preferences without a restart, and connections opened for a single +operation are released again afterwards. + +This is what makes direct message delivery possible with one connection mechanism. NIP-17 +delivery targets the *recipient's* relays, which the pool has typically never heard of. An +immutable pool would force delivery to open ad-hoc connections outside it, creating a second +code path for connection handling, health, and shutdown. + +Transient connections are released by reference counting or idle eviction, so relays added for +one delivery do not accumulate over a long-running process. + +See ADR-0005. + +**Blocked by:** 03 (Per-relay serialization and connection health). + +**Status:** done + +- [x] Relays can be added to a running pool and immediately participate in operations +- [x] Relays can be removed from a running pool, closing their connection +- [x] A relay added for a single operation is released once no longer in use, by reference + counting or idle eviction +- [x] Adding a relay already in the pool does not open a second connection +- [x] Removing a relay does not disturb in-flight operations on other relays +- [x] Tests cover add, remove, duplicate add, and release of a transiently added relay +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/07-relay-list-lookup.md b/.scratch/nostr-java-api/issues/07-relay-list-lookup.md new file mode 100644 index 00000000..0c5fee04 --- /dev/null +++ b/.scratch/nostr-java-api/issues/07-relay-list-lookup.md @@ -0,0 +1,26 @@ +# 07: Relay list lookup (kind 10050) + +**What to build:** A developer can discover which relays a given user reads direct messages +from, resolved from that user's published kind 10050 relay list. + +`DirectMessageRelayLookup` exists in `nostr-java-identity` as a seam with no implementation, +because a real one must fetch events, and `identity` is deliberately transport-free. This +ticket provides that implementation in the new `nostr-java-api` module, fetching relay lists +through the pool and injecting it into `identity`'s interface, so the dependency arrow points +from `api` to `identity` and never back. + +A relay list is *data*, distinct from the relay pool, which is a set of live connections. See +`docs/CONTEXT.md`. + +**Blocked by:** 04 (Fan-in subscriptions with de-duplication). + +**Status:** done + +- [x] Given a public key, the lookup returns the relays from that user's kind 10050 event +- [x] The lookup implements `DirectMessageRelayLookup` without adding any dependency to + `nostr-java-identity` +- [x] A user with no published relay list yields an empty result rather than an error +- [x] Results are fetched through the relay pool, not through a bespoke connection +- [x] Tests cover a user with a relay list, a user without one, and a user whose list is + served by only some of the queried relays +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/08-nip17-direct-message-delivery.md b/.scratch/nostr-java-api/issues/08-nip17-direct-message-delivery.md new file mode 100644 index 00000000..5900c79b --- /dev/null +++ b/.scratch/nostr-java-api/issues/08-nip17-direct-message-delivery.md @@ -0,0 +1,34 @@ +# 08: NIP-17 direct message delivery + +**What to build:** A developer sends a private direct message in one call and learns who +received it. + +Today `Nip17DirectMessageService.planDelivery` produces a `List` that nothing +executes: the plan names each recipient, their gift wrap, and the relays it should go to, and +then stops. That is by design, because `identity` must stay transport-free. This ticket +executes the plan, which is exactly the orchestration a capability layer exists for. + +For each recipient the delivery resolves their relays, adds them to the pool, publishes their +gift wrap there, and releases the transient connections afterwards. Recipients with no +published relay list are reported as unreachable, never silently skipped, so the application +can tell the user their message did not arrive. + +Reading is symmetrical: an incoming gift wrap is read back into a chat message through the +same service. + +See ADR-0003 and ADR-0005. + +**Blocked by:** 06 (Runtime pool membership), 07 (Relay list lookup). + +**Status:** done + +- [x] Sending a direct message composes the plan and delivers each gift wrap to its own + recipient's relays +- [x] Delivery returns a per-recipient outcome so a group message reports who received it +- [x] A recipient with no published relay list is reported unreachable, not skipped silently +- [x] Relays connected solely for a delivery are released afterwards +- [x] An incoming gift wrap can be read back into a chat message through the same service +- [x] `nostr-java-identity` gains no new dependencies +- [x] Tests cover single-recipient delivery, group delivery with mixed outcomes, an unreachable + recipient, transient relay release, and the read-back path +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/09-nostr-client-facade.md b/.scratch/nostr-java-api/issues/09-nostr-client-facade.md new file mode 100644 index 00000000..7060a83a --- /dev/null +++ b/.scratch/nostr-java-api/issues/09-nostr-client-facade.md @@ -0,0 +1,44 @@ +# 09: `NostrClient` facade and module wiring + +**What to build:** A developer configures their identity and relays once, then writes code in +terms of intent: + + try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz", "wss://nos.lol") + .build()) { + nostr.publish().textNote("Hello Nostr!"); + nostr.directMessages().send(recipient, "hi"); + } + +This ticket is last because it is only an assembly of parts already proven. It creates the +`nostr-java-api` module itself, extending the chain to +`core → event → identity → client → api`, with nothing depending on `api`. + +The facade knows the caller's identity, which is what collapses build-sign-publish into one +step, with a per-call override for bots acting for several keys. It returns core types such as +`GenericEvent`, so no parallel event model appears and callers may still drop down to +`RelayPool` or build events by hand. + +Ownership follows construction: a pool built from URIs is closed by `NostrClient`, a pool +passed in by the caller is not. Construction must work in a plain `main()` with no Spring +application context, even though `nostr-java-client` uses Spring internally. + +See ADR-0001 and ADR-0005. + +**Blocked by:** 05 (Synthetic EOSE and mid-stream recovery), 08 (NIP-17 direct message +delivery). + +**Status:** done + +- [x] `nostr-java-api` module is added to the build with the correct dependency direction +- [x] `NostrClient.builder()` accepts an identity and either relay URIs or an existing pool +- [x] `publish()`, `subscriptions()`, `directMessages()`, and relay-list lookup are exposed, + each behind an interface +- [x] Events are signed with the configured identity, with a per-call identity override +- [x] A pool built from URIs is closed by `NostrClient`; a supplied pool is not +- [x] The ownership rule is documented on the builder method itself +- [x] The client is constructible in a plain `main()` with no Spring application context +- [x] Facade methods return core types, introducing no parallel event model +- [x] Tests cover signing, per-call override, both ownership cases, and Spring-free construction +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/issues/10-documentation-and-round-trip-test.md b/.scratch/nostr-java-api/issues/10-documentation-and-round-trip-test.md new file mode 100644 index 00000000..a6e23151 --- /dev/null +++ b/.scratch/nostr-java-api/issues/10-documentation-and-round-trip-test.md @@ -0,0 +1,24 @@ +# 10: Documentation and round-trip integration test + +**What to build:** A developer discovering the SDK finds the multi-relay path documented as the +normal way to use it, and the project has end-to-end proof it works against a real relay. + +Everything before this ticket is verified against `FakeRelay`, which is the right seam for +failure scenarios but never touches a socket. One round trip against a live or containerised +relay closes that gap: publish an event, read it back through a subscription. + +**Blocked by:** 09 (`NostrClient` facade and module wiring). + +**Status:** done + +- [x] An integration test publishes an event and reads it back via a subscription against a + real relay, following the repo's existing Docker / no-docker profile split +- [x] A how-to guide covers multi-relay publishing, subscribing, and direct messages, filed + under `docs/howto` per Diátaxis and linked from `docs/README.md` +- [x] The API reference documents `NostrClient`, `RelayPool`, and `PublishResult` +- [x] The README module table and architecture section include `nostr-java-api` +- [x] `docs/explanation/architecture.md` reflects the five-module chain +- [x] `CHANGELOG.md` records the new module under `Added` +- [x] The known in-flight ceiling and the transient-relay sharing note are documented so they + are not later mistaken for bugs +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-api/spec.md b/.scratch/nostr-java-api/spec.md new file mode 100644 index 00000000..c6fbaeb0 --- /dev/null +++ b/.scratch/nostr-java-api/spec.md @@ -0,0 +1,272 @@ +# Spec: `nostr-java-api` capability layer + +- **Status**: Ready for agent +- **Date**: 2026-08-30 +- **Decisions**: [ADR-0001](../../docs/decisions/0001-introduce-nostr-java-api-module.md) … + [ADR-0005](../../docs/decisions/0005-pool-membership-eose-and-ownership.md) +- **Vocabulary**: [docs/CONTEXT.md](../../docs/CONTEXT.md) + +## Problem Statement + +A developer using `nostr-java` today can build, sign, and send an event, but only to **one +relay at a time**. Nostr is a multi-relay protocol, so that leaves them writing the same +infrastructure every time: connect to several relays, publish to all of them, work out whether +enough of them accepted, merge inbound events from all of them, throw away the duplicates, and +notice when one quietly drops off. + +Three concrete gaps follow: + +- Publishing to several relays, and understanding a **partial** result, is entirely manual. +- Subscribing across several relays means de-duplicating by event id by hand, and a relay that + disconnects mid-stream silently stops contributing. +- NIP-17 direct messages can be *planned* (`Nip17DirectMessageService.planDelivery`) but not + *sent*, because delivery targets the recipient's relays and nothing resolves or connects to + them. `DirectMessageRelayLookup` has no implementation at all. + +## Solution + +A new `nostr-java-api` module exposing a `NostrClient` facade, built on a new `RelayPool` in +`nostr-java-client`. The developer names their relays and their identity once, then works in +terms of intent: + +```java +try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz", "wss://nos.lol") + .build()) { + + PublishResult result = nostr.publish().textNote("Hello Nostr!"); + nostr.directMessages().send(recipient, "hi"); + nostr.subscriptions().subscribe(filter, event -> render(event)); +} +``` + +Partial failure is visible in `PublishResult` rather than hidden. Duplicates are removed. A +dropped relay is reported and re-subscribed on reconnect. Direct messages are delivered to the +recipient's own relays, discovered from their kind 10050 relay list. + +The facade is a **capability layer**, not a wrapper: callers may still build `GenericEvent` +directly and drop to `RelayPool` or `NostrRelayClient` when they want to. + +## User Stories + +1. As an application developer, I want to configure several relays once, so that I do not + repeat connection setup at every call site. +2. As an application developer, I want to publish an event to all my relays in one call, so + that I do not write fan-out logic myself. +3. As an application developer, I want the facade to sign events with my configured identity, + so that build-sign-publish is a single step. +4. As a bot author running several accounts, I want to override the identity per call, so that + one client can act for many keys. +5. As an application developer, I want to see per-relay publish outcomes, so that I can tell + which relays accepted my event and which rejected it. +6. As an application developer, I want a relay's rejection reason surfaced verbatim, so that I + can act on `blocked:`, `rate-limited:`, and `invalid:` differently. +7. As an application developer, I want an exception when no relay at all accepted my event, so + that total failure cannot pass silently for success. +8. As an application developer, I want partial success to be an ordinary return value, so that + the common case of one flaky relay is not an exception. +9. As an application developer, I want publishing to be bounded by a timeout, so that one + unresponsive relay cannot hang my call indefinitely. +10. As an application developer, I want relays that miss the timeout recorded as timed out, so + that I can distinguish slowness from rejection. +11. As an application developer, I want my client to start even when some relays are + unreachable, so that one dead relay cannot stop my application booting. +12. As an operator, I want per-relay connection state observable, so that I can monitor which + relays are actually carrying traffic. +13. As an application developer, I want unreachable relays retried in the background, so that + they rejoin the pool without a restart. +14. As an application developer, I want one subscription to cover all my relays, so that I do + not manage a subscription per relay. +15. As an application developer, I want the same event arriving from several relays delivered + to me once, so that my UI does not show duplicates. +16. As an application developer running a long-lived firehose, I want de-duplication memory to + stay bounded, so that my process does not leak over days of uptime. +17. As an application developer, I want subscription callbacks to receive parsed events, so + that I am not decoding JSON myself. +18. As an application developer, I want one signal when the stored-event backlog is drained + across all relays, so that I can hide a loading spinner at the right moment. +19. As an application developer, I want that signal to arrive even when a relay never responds, + so that one dead relay cannot leave my UI loading forever. +20. As an application developer, I want to be notified when a relay drops mid-subscription, so + that silent degradation from five relays to one is visible. +21. As an application developer, I want dropped relays automatically re-subscribed on + reconnect, so that my long-lived stream repairs itself. +22. As an application developer, I want to close a subscription and stop receiving events from + every relay at once, so that cleanup is a single call. +23. As an application developer, I want malformed relay payloads reported without killing my + subscription, so that one bad relay cannot end my stream. +24. As a messaging application developer, I want to send a NIP-17 direct message in one call, + so that I do not orchestrate rumor, seal, gift wrap, and delivery myself. +25. As a messaging application developer, I want the message delivered to the recipient's own + relays, so that they actually receive it. +26. As a messaging application developer, I want the recipient's relay list resolved from their + kind 10050 event, so that I do not maintain that mapping. +27. As a messaging application developer, I want a recipient with no published relay list + reported as unreachable rather than silently skipped, so that I can tell the user. +28. As a messaging application developer, I want per-recipient delivery outcomes for a group + message, so that I know who received it. +29. As a messaging application developer, I want relays connected only for a delivery to be + released afterwards, so that connections do not accumulate. +30. As a messaging application developer, I want to read an incoming gift wrap back into a + chat message through the same facade, so that send and receive are symmetrical. +31. As an application developer, I want to add and remove relays at runtime, so that my relay + set can follow user preferences without a restart. +32. As an application developer, I want `NostrClient` to close the pool it created, so that + try-with-resources cleans everything up. +33. As an application developer embedding the SDK in a container, I want to pass my own pool + and keep ownership of it, so that its lifecycle can outlive the facade. +34. As an application developer, I want to construct the client in a plain `main()` method, so + that I am not forced to run a Spring application context. +35. As an application developer, I want publish and read calls to be synchronous by default, + so that ordinary code reads top to bottom. +36. As an application developer, I want the facade to return core types like `GenericEvent`, + so that I am not converting between a facade model and the SDK model. +37. As a contributor, I want relay behaviour fakeable in tests, so that I can test failure + scenarios without Docker or a live relay. +38. As a contributor, I want the pool tested against scripted relay failures, so that partial + failure semantics are verified rather than assumed. + +## Implementation Decisions + +### Modules + +- **`nostr-java-client`** gains the relay pool and the connection seam. The pool is a transport + concern (ADR-0001), so it must be usable without the facade. +- **`nostr-java-api`** is new, depends on `nostr-java-client`, and nothing depends on it. Build + order becomes `core → event → identity → client → api`. +- **`nostr-java-identity`** is unchanged. It stays transport-free; `api` executes its plans. + +### The connection seam + +A `RelayConnection` interface is extracted in `nostr-java-client`, exposing only what a pool +needs: connect, send, subscribe, connection state, close. It deliberately does **not** mirror +all of `NostrRelayClient`'s public methods. `NostrRelayClient` becomes its production +implementation, and a `RelayConnectionFactory` maps a relay URI to a connection so the pool can +be given fakes in tests. + +This is the single seam for the whole feature. It is the highest point at which real I/O +begins, so everything above it is pure logic, and it inverts the Spring dependency: `api` +depends on the interface, never on Spring types. + +### `RelayPool` + +- **Mutable membership** (ADR-0005): relays can be added and removed at runtime. Connections + opened transiently for a direct message are released by reference counting or idle eviction. +- **Best-effort construction** (ADR-0002): unreachable relays are marked down and retried + lazily; construction never fails because a member is unreachable. +- **Serialized per relay** (ADR-0004): `NostrRelayClient` permits one request in flight per + connection, so operations to a single relay queue behind a lock. Fan-out across relays stays + concurrent, on Virtual Threads. +- Owns per-relay `ConnectionState`, reconnection policy, and subscription replay. + +### Publishing + +`publish` fans out, waits for each relay's `OK` up to a pool-level timeout, and returns a +`PublishResult`: for each relay, accepted, rejected with the relay's verbatim reason, or timed +out. It **throws when zero relays accepted** (ADR-0002). There is no quorum setting and no +all-or-nothing mode; callers wanting stricter guarantees inspect the result. + +### Subscriptions + +- Fan-in across relays, de-duplicated by event `id` through a **bounded LRU window**, size + configurable, defaulting to the low thousands (ADR-0002). +- Callbacks receive **`GenericEvent`**, since the pool must parse payloads to de-duplicate + anyway (ADR-0004). +- Per-relay `EOSE` frames are aggregated into **one synthetic EOSE**, emitted when every + participating relay has reported or the timeout expires (ADR-0005). +- A relay dropping mid-stream triggers an error notification and, on reconnect, automatic + re-subscription from the stored filter. Subscriptions are therefore stateful objects holding + their filter, not fire-and-forget handles. +- Closing the subscription handle unsubscribes from every relay. + +### Direct messages + +`api` orchestrates: it calls `Nip17DirectMessageService.planDelivery`, resolves each +recipient's relays, adds those relays to the pool, publishes each gift wrap to its recipient's +relays, and returns per-recipient outcomes. `MessageDelivery.unreachable` recipients are +reported, never silently dropped. + +`DirectMessageRelayLookup` gets its first real implementation here, fetching kind 10050 events +through the pool. It is injected into `identity`'s interface, so the dependency arrow keeps +pointing from `api` to `identity`. + +### Facade + +`NostrClient` is built by a builder taking an identity and either relay URIs or a pool. It +exposes `publish()`, `subscriptions()`, `directMessages()`, and the relay-list lookup, each an +interface (DIP). Identity is set at build time with a per-call override. + +**Ownership follows construction** (ADR-0005): a pool built from URIs is closed by +`NostrClient`; a pool passed in is not. This must be documented on the builder method itself. + +Construction must work with no Spring application context. A Spring Boot starter is explicitly +a later, separate decision. + +### API scope + +v1 ships `publish`, `subscriptions`, `directMessages`, and the relay-list lookup. General +profile handling (kind 0 metadata, kind 3 contacts, kind 10002) is deferred as convenience +rather than capability (ADR-0003). Any proposed method that only reorders calls the caller +could already make is out. + +## Testing Decisions + +**What makes a good test here.** Tests assert what a caller observes: the contents of a +`PublishResult`, which events reach a subscription callback, in what order, and how many times, +whether an exception is thrown. They must not assert on lock acquisition, thread counts, or +which internal method ran. The existing client tests reach into a package-private constructor +with a mocked `WebSocketSession`; the new seam replaces that with a fake, so new tests need no +Mockito at all. + +**The fake.** A `FakeRelay` implementing `RelayConnection`, scriptable per scenario: accept, +reject with a reason string, never respond, drop mid-stream, emit a given event sequence, delay +or withhold `EOSE`. This is the only test double in the feature. + +**Modules and levels.** + +- `RelayPool` unit tests against fakes, covering: 3-accept/1-reject/1-timeout fan-out; zero + acceptances throwing; construction with an unreachable member; runtime add and remove; + per-relay serialization under concurrent publishes; reference-counted release. +- Subscription tests against fakes: duplicate event from four relays delivered once; LRU window + eviction bounded; synthetic EOSE after all relays report; synthetic EOSE despite one silent + relay; drop mid-stream notifies and re-subscribes on reconnect; malformed payload reported + without ending the stream; closing the handle stops all relays. +- Direct message tests: recipient with a relay list is delivered to those relays; recipient + without one is reported unreachable; group message yields per-recipient outcomes; + transiently added relays released afterwards. +- `NostrClient` tests: identity signing and per-call override; ownership, a built pool is + closed and a supplied pool is not; construction with no Spring context. +- One integration test against a live or containerised relay for the publish-then-read round + trip, following the repo's existing Docker/no-docker profile split. + +**Prior art.** `NostrRelayClientSubscriptionTest` and `NostrRelayClientConcurrencyTest` for +latch-based async assertions; `Nip17DirectMessageServiceTest` and `MessageDeliveryPlanTest` for +the direct-message domain; `EntityFactory` in `nostr-java-identity` for test data construction. +Every test method carries a plain-English comment above it, per repo convention. + +## Out of Scope + +- **Multiplexing `NostrRelayClient`.** The one-request-in-flight ceiling stands; per-relay + throughput remains latency-bound. Tracked as separate follow-up work. +- **A Spring Boot starter.** Plain-Java construction only for now. +- **General profile services**: kind 0 metadata, kind 3 contacts, kind 10002 relay lists beyond + the direct-message case. +- **Event persistence or caching** beyond the bounded de-duplication window. No local store. +- **NIP-42 relay authentication**, **NIP-65 outbox routing**, and **negentropy sync**. +- **Rewriting existing client tests** onto the new seam. Worthwhile, but not a blocker. +- **Async variants** of every facade method. Sync-first; async is added only where fan-out + blocking proves material. + +## Further Notes + +Two accepted consequences are worth restating, because they will look like bugs later: + +- A relay added transiently to deliver a direct message is briefly usable by other operations + while it remains connected. This is benign sharing, not a leak, but it is real. +- Events evicted from the de-duplication window can be re-delivered. The default window size + must comfortably exceed realistic cross-relay arrival spread. + +Delivery order is dependency order: `RelayConnection` seam, then `RelayPool`, then publishing, +then subscriptions, then relay-list lookup, then direct messages, then the facade, then docs. +The facade is last because it is only an assembly of parts already proven. diff --git a/.scratch/nostr-java-mcp/ACCEPTANCE.md b/.scratch/nostr-java-mcp/ACCEPTANCE.md new file mode 100644 index 00000000..732dc5d3 --- /dev/null +++ b/.scratch/nostr-java-mcp/ACCEPTANCE.md @@ -0,0 +1,38 @@ +# End-to-end acceptance check + +Drives the **shipped jar** as an MCP host does, over stdio, against a real relay, and asserts +one requirement per ticket checklist item. It exists because the module's own tests exercise +classes through their own seams: they can all pass while the artefact a user actually runs +behaves differently. That is not hypothetical, it is how the bound-server `identity` argument +defect was found. + +## Running it + +```bash +# A relay. Retry until it starts without the po2_denom panic, which leaves it accepting +# connections while silently answering nothing. +docker run -d --name nostr-acceptance-relay -p 127.0.0.1:18777:8080 scsibug/nostr-rs-relay:0.8.13 +docker logs nostr-acceptance-relay 2>&1 | grep po2_denom # if this matches, recreate it + +mvn -o -pl nostr-java-mcp package -DskipTests +python3 .scratch/nostr-java-mcp/accept.py +``` + +Expected: `33/33 requirements verified against the shipped jar`. + +## What it covers + +Every substantive checklist item across the twelve tickets: the CLI creating keys without +printing them, the full 22-tool surface, key secrecy across the whole surface, refusing to guess +between identities, confirm-then-publish including single-use tokens, querying published events +back, identifier and timestamp handling, profile round trips, subscription open/drain/close, +contacts, the DM decryption opt-in, the absence of NIP-04, prompts and resources, and the three +policy modes (bound, read-only, environment-configured). + +## Known flake, and why it is not the product + +`nostr-rs-relay:0.8.13` sometimes panics during startup (`po2_denom was zero!`) and then accepts +connections while storing nothing. The harness probes by publishing and requiring acceptance, +rather than by connecting, because a connection succeeds against an inert relay. If the probe +gives up, recreate the container. The Maven ITs handle this with +`RelayStoresEventsWaitStrategy` and `withStartupAttempts`. diff --git a/.scratch/nostr-java-mcp/accept.py b/.scratch/nostr-java-mcp/accept.py new file mode 100644 index 00000000..6de442cd --- /dev/null +++ b/.scratch/nostr-java-mcp/accept.py @@ -0,0 +1,196 @@ +import sys, json, time, subprocess, os +sys.path.insert(0, '.') +from harness import Server, text + +JAR = "/home/eric/IdeaProjects/nostr-java/nostr-java-mcp/target/nostr-java-mcp-2.2.0-runnable.jar" +RELAY = "ws://127.0.0.1:18777" +KS = "/tmp/nostr-mcp-acceptance/keys.p12" +ENV = {"NOSTR_MCP_KEYSTORE_PASSPHRASE": "acceptance-passphrase"} +BASE = ["-Dnostr.mcp.keystore.type=encrypted-file", + f"-Dnostr.mcp.keystore.path={KS}", + f"-Dnostr.mcp.relays.read={RELAY}"] + +def await_relay_storing_events(): + """The relay binds its port before it can store anything, and a startup panic + (po2_denom was zero!) leaves it accepting connections while silently answering nothing. + Querying still succeeds against such a relay, so the only dependable probe is the + behaviour the tests need: publish an event and require it to be accepted.""" + import os + e = dict(os.environ); e.update(ENV) + cli("keygen", "probe-key") + for attempt in range(20): + probe = Server(JAR, BASE, ENV) + try: + probe.initialize() + r1 = probe.tool("nostr_publish_note", {"content": f"probe {attempt}", "identity": "probe-key"}) + tok = r1.get("structuredContent", {}).get("confirmationToken") + if tok: + r2 = probe.tool("nostr_publish_note", + {"content": f"probe {attempt}", "identity": "probe-key", + "confirmationToken": tok}) + if not r2.get("isError"): + return + except Exception: + pass + finally: + probe.close() + time.sleep(3) + raise SystemExit("the relay never stored an event; restart the container and retry") + +# Each run starts from an empty keystore and a fresh relay, so a repeated run measures the +# product rather than the residue of the previous one. +if os.path.exists(KS): + os.remove(KS) + +results = [] +def check(requirement, ok, evidence): + results.append((requirement, ok, evidence)) + print(("PASS " if ok else "FAIL ") + requirement) + print(" " + str(evidence)[:200]) + +def cli(*args): + import os + e = dict(os.environ); e.update(ENV) + return subprocess.run(["java"] + BASE + ["-jar", JAR] + list(args), + capture_output=True, text=True, env=e, timeout=120).stdout + +await_relay_storing_events() + +# --- Ticket 04: CLI creates keys; keys never printed +out = cli("keygen", "personal") +check("04 CLI keygen creates an identity", "Created identity 'personal'" in out, out.strip().splitlines()[:2]) +check("04 CLI never prints the private key", "nsec" not in out, "no nsec in output") +cli("keygen", "project-bot") +check("04 CLI list shows both identities", + "personal" in cli("list") and "project-bot" in cli("list"), cli("list").split()) + +# --- Ticket 03/07: unbound multi-identity server +s = Server(JAR, BASE, ENV); s.initialize() +tools = s.tools() +check("02/05/06/07/08/09 full tool surface registered", len(tools) == 22, f"{len(tools)} tools") +ids = s.tool("nostr_list_identities") +check("03 identities listed with public keys only", + "personal" in json.dumps(ids) and "nsec" not in json.dumps(ids), text(ids)[:120]) +check("03 no private key anywhere in the surface", + not any(k in json.dumps(s.call("tools/list")) for k in ["nsec1", "privateKey"]), + "swept tools/list for key material") + +# --- Ticket 06: ambiguity refused rather than guessed +r = s.tool("nostr_publish_note", {"content": "should not publish"}) +check("06 signing refuses to guess between identities", + r.get("isError") and "IDENTITY_AMBIGUOUS" in text(r), text(r)[:140]) + +# --- Ticket 06: confirm-then-publish +r1 = s.tool("nostr_publish_note", {"content": "acceptance note", "identity": "personal"}) +tok = r1.get("structuredContent", {}).get("confirmationToken") +check("06 first call previews without publishing", + tok and "Nothing has been published yet" in text(r1), text(r1)[:100]) +def key_of(alias, field="publicKey"): + return next(i[field] for i in ids["structuredContent"]["identities"] if i["alias"] == alias) + +q = s.tool("nostr_query_events", {"authors": [key_of("personal")], "kinds": [1]}) +check("06 preview really published nothing", q["structuredContent"]["count"] == 0, text(q)) +r2 = s.tool("nostr_publish_note", {"content": "acceptance note", "identity": "personal", "confirmationToken": tok}) +check("06 confirmed call publishes", text(r2).startswith("Published "), text(r2)[:100]) +check("06 token is single-use", + s.tool("nostr_publish_note", {"content": "x", "identity": "personal", "confirmationToken": tok}).get("isError"), + "replay refused") + +# --- Ticket 05: read back what we published +time.sleep(1) +pk = key_of("personal") +q = s.tool("nostr_query_events", {"authors": [pk], "kinds": [1]}) +check("05 published note is queryable", q["structuredContent"]["count"] == 1, text(q)) +check("05 npub accepted where hex is", + s.tool("nostr_query_events", {"authors": [key_of("personal", "npub")]})["structuredContent"]["count"] >= 1, + "npub query matched") +check("05 bad identifier gives a stable code", + text(s.tool("nostr_get_profile", {"pubkey": "nonsense"})).startswith("INVALID_ARGUMENT"), + text(s.tool("nostr_get_profile", {"pubkey": "nonsense"}))[:80]) + +# --- Ticket 05: relative time normalisation +check("05 relative timestamps accepted", + not s.tool("nostr_query_events", {"authors": [pk], "since": "24h"}).get("isError"), "since=24h accepted") + +# --- Ticket 06: profile round trip +s.tool("nostr_update_profile", {"name": "acceptance", "about": "e2e", "identity": "personal"}) +tok = s.tool("nostr_update_profile", {"name": "acceptance", "about": "e2e", "identity": "personal"})["structuredContent"]["confirmationToken"] +s.tool("nostr_update_profile", {"name": "acceptance", "about": "e2e", "identity": "personal", "confirmationToken": tok}) +time.sleep(1) +p = s.tool("nostr_get_profile", {"pubkey": pk}) +check("05/06 profile publishes and decodes", "acceptance: e2e" == text(p), text(p)) + +# --- Ticket 08: subscriptions +sub = s.tool("nostr_subscribe", {"authors": [pk], "kinds": [1]}) +sid = sub["structuredContent"]["subscriptionId"] +check("08 subscribe returns an id without waiting for backlog", + sid and sub["structuredContent"]["backlogDrained"] is False, text(sub)[:110]) +time.sleep(3) +rd = s.tool("nostr_read_subscription", {"subscriptionId": sid}) +first = rd["structuredContent"]["count"] +rd2 = s.tool("nostr_read_subscription", {"subscriptionId": sid}) +check("08 reading drains, so events are not repeated", + first >= 1 and rd2["structuredContent"]["count"] == 0, f"first read {first}, second {rd2['structuredContent']['count']}") +check("08 list reports the open subscription", sid in text(s.tool("nostr_list_subscriptions")), text(s.tool("nostr_list_subscriptions"))[:90]) +s.tool("nostr_unsubscribe", {"subscriptionId": sid}) +check("08 unsubscribed id is then unknown", + text(s.tool("nostr_read_subscription", {"subscriptionId": sid})).startswith("SUBSCRIPTION_UNKNOWN"), "closed") + +# --- Ticket 09: contacts and DM refusal +check("09 contacts reports an absent list as an answer", + not s.tool("nostr_get_contacts", {"pubkey": pk}).get("isError"), text(s.tool("nostr_get_contacts", {"pubkey": pk}))[:90]) +dm = s.tool("nostr_read_direct_messages", {"identity": "personal"}) +check("09 DM decryption is off unless enabled", dm.get("isError") and "not enabled" in text(dm), text(dm)[:110]) +check("09 no NIP-04 tool is offered", not any("nip04" in t for t in tools), "surface has no nip04 tool") + +# --- Ticket 12: prompts and resources +prompts = [p["name"] for p in s.call("prompts/list")["result"]["prompts"]] +check("12 guided prompts are offered", + prompts == ["compose-note", "catch-up-feed", "watch-mentions"], prompts) +gp = s.call("prompts/get", {"name": "compose-note", "arguments": {"topic": "t"}}) +check("12 prompt returns instructions naming the confirmation step", + "confirmationToken" in json.dumps(gp), "instructions include confirmationToken") +res = [r["uri"] for r in s.call("resources/list")["result"]["resources"]] +check("12 identity and relay resources exposed", + any("identity" in u for u in res) and any("relay" in u for u in res), res) +s.close() + +# --- Ticket 04: bound server +b = Server(JAR, BASE + ["-Dnostr.mcp.identity=personal"], ENV); b.initialize() +bt = b.tools() +bids = b.tool("nostr_list_identities") +check("04 bound server sees only its own identity", + "project-bot" not in json.dumps(bids) and "personal" in json.dumps(bids), text(bids)[:110]) +check("04 bound server registers no keystore-mutating tools", + not any(t in bt for t in ["nostr_create_identity", "nostr_remove_identity"]), f"{len(bt)} tools, no lifecycle") +check("04 bound server needs no identity argument", + "identity" not in json.dumps([t for t in b.call("tools/list")["result"]["tools"] if t["name"] == "nostr_publish_note"][0]["inputSchema"]), + "publish schema has no identity argument") +b.close() + +# --- Ticket 06/07: read-only server +d = Server(JAR, BASE + ["-Dnostr.mcp.write-policy=deny"], ENV); d.initialize() +dt = d.tools() +check("06 read-only server registers no write tool", not any("publish" in t for t in dt), f"{len(dt)} tools") +check("07 read-only server cannot mutate the keystore either", + not any("create_identity" in t or "remove_identity" in t for t in dt), "no lifecycle tools") +d.close() + +# --- Ticket 11: environment configuration +e = Server(JAR, BASE, dict(ENV, NOSTR_MCP_WRITE_POLICY="deny")); e.initialize() +check("11 hyphenated settings configurable from the environment", + not any("publish" in t for t in e.tools()), "NOSTR_MCP_WRITE_POLICY=deny took effect") +e.close() + +# --- Ticket 07: bound server refuses a missing identity +import os +env = dict(os.environ); env.update(ENV) +p = subprocess.run(["java"] + BASE + ["-Dnostr.mcp.identity=nonexistent", "-jar", JAR], + capture_output=True, text=True, env=env, timeout=90) +check("04 server bound to a missing identity refuses to start", + p.returncode != 0 and "keygen" in (p.stdout + p.stderr), "refused, naming the fix") + +print() +passed = sum(1 for _, ok, _ in results if ok) +print(f"=== {passed}/{len(results)} requirements verified against the shipped jar") +sys.exit(0 if passed == len(results) else 1) diff --git a/.scratch/nostr-java-mcp/harness.py b/.scratch/nostr-java-mcp/harness.py new file mode 100644 index 00000000..b1ba0b4e --- /dev/null +++ b/.scratch/nostr-java-mcp/harness.py @@ -0,0 +1,47 @@ +"""Drives the shipped jar as an MCP host does, over stdio, against a real relay.""" +import json, subprocess, sys, threading, time, itertools + +class Server: + def __init__(self, jar, props, env=None): + import os + e = dict(os.environ); e.update(env or {}) + self.p = subprocess.Popen( + ["java"] + props + ["-jar", jar], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, bufsize=1, env=e) + self.ids = itertools.count(1) + + def call(self, method, params=None, notify=False): + msg = {"jsonrpc": "2.0", "method": method} + if params is not None: msg["params"] = params + if not notify: + msg["id"] = next(self.ids) + self.p.stdin.write(json.dumps(msg) + "\n"); self.p.stdin.flush() + if notify: return None + while True: + line = self.p.stdout.readline() + if not line: raise RuntimeError("server closed the stream") + try: reply = json.loads(line) + except Exception: continue + if reply.get("id") == msg["id"]: return reply + + def initialize(self): + self.call("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "acceptance", "version": "1"}}) + self.call("notifications/initialized", notify=True) + + def tools(self): + return [t["name"] for t in self.call("tools/list")["result"]["tools"]] + + def tool(self, name, args=None): + return self.call("tools/call", {"name": name, "arguments": args or {}})["result"] + + def close(self): + self.p.terminate() + try: self.p.wait(timeout=10) + except Exception: self.p.kill() + +def text(result): + for c in result.get("content", []): + if c.get("type") == "text": return c["text"] + return "" diff --git a/.scratch/nostr-java-mcp/issues/01-contactlist-type-for-kind-3.md b/.scratch/nostr-java-mcp/issues/01-contactlist-type-for-kind-3.md new file mode 100644 index 00000000..ef5a0dc1 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/01-contactlist-type-for-kind-3.md @@ -0,0 +1,27 @@ +# 01: `ContactList` type over kind-3 in `nostr-java-event` + +**What to build:** A developer can read and write a Nostr contact list without parsing tags +themselves, the same way `DirectMessageRelayList` handles kind-10050 today. + +This is the MCP module's only SDK prerequisite. `Kinds.CONTACT_LIST = 3` exists but nothing +models the event, so `nostr_get_contacts` has nothing to call. The type belongs here rather +than in the MCP module because every consumer of the SDK benefits, and an MCP server parsing +`p` tags inline would be the only place in the codebase that knows how a contact list is +shaped. + +The design is already proven: a prototype following the `DirectMessageRelayList` pattern +round-tripped through a live relay, published and recovered with its contacts intact, and its +kind guard rejected a kind-1 event. See `McpSpecAssumptionsIT`. + +**Blocked by:** None (can start immediately). + +**Status:** done + +- [x] `ContactList.from(GenericEvent)` reads `p` tags into public keys +- [x] `toEvent()` renders the list back as an unsigned kind-3 event +- [x] A wrong-kind event is rejected with a message naming the expected and actual kinds +- [x] An event with no `p` tags yields an empty list rather than failing +- [x] Tags that are not contacts are ignored rather than misread +- [x] Round-trip is covered, including through a relay in an integration test +- [x] `CHANGELOG.md` records the addition +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/02-module-skeleton-and-stdio-transport.md b/.scratch/nostr-java-mcp/issues/02-module-skeleton-and-stdio-transport.md new file mode 100644 index 00000000..7590cab0 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/02-module-skeleton-and-stdio-transport.md @@ -0,0 +1,27 @@ +# 02: Module skeleton with MCP stdio transport + +**What to build:** An MCP host such as Claude Desktop can launch `nostr-java-mcp` over stdio, +see its tool list, and call a read-only tool that reports the configured relays and their +connection state. + +This is the tracer bullet for the whole module: transport, tool registration, configuration and +the SDK underneath, proven end to end by one tool that needs no keystore and writes nothing. + +The module depends on `nostr-java-api` and reaches relays through `NostrClient`. It must not +reach past that to `NostrRelayClient`, which would rebuild connection management, result +aggregation and de-duplication that the SDK already owns. + +**Blocked by:** None (can start immediately). + +**Status:** done + +- [x] `nostr-java-mcp` is added to the build, depending on `nostr-java-api`, with nothing + depending on it +- [x] The server starts over stdio using the official MCP SDK and responds to tool discovery +- [x] `NostrToolRegistry` declares tools one class per tool, so a new tool is a new class + rather than an edit to a switch +- [x] `nostr_list_relays` returns the configured relays with their connection state +- [x] `RelayDirectory` resolves logical relay names to the URIs handed to `NostrClient` +- [x] Configuration binds under `nostr.mcp.*` and works with no hand-written config file +- [x] A golden-file test pins the tool list so surface changes are visible in review +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/03-identity-vault-and-keystore.md b/.scratch/nostr-java-mcp/issues/03-identity-vault-and-keystore.md new file mode 100644 index 00000000..482b64f0 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/03-identity-vault-and-keystore.md @@ -0,0 +1,29 @@ +# 03: `IdentityVault` and keystore backends + +**What to build:** The server loads signing keys at startup from the platform keychain or an +encrypted file, and an agent can see which identities exist without ever being able to see a +key. + +Keys are the one thing in this module that cannot be un-leaked, so the vault boundary is the +module's central security property: tools pass an alias to a signing service and receive a +signed event; `Identity` objects never reach the tool layer. + +`os-keychain` is the default because it needs no passphrase and so does not block unattended +startup. `encrypted-file` is the portable fallback and the right choice in a container, where +there is no keychain to talk to. + +**Blocked by:** 02 (Module skeleton with MCP stdio transport). + +**Status:** ready-for-agent + +- [x] `KeySource` has `os-keychain`, `encrypted-file` and `env` implementations chosen by + `keystore.type`, defaulting to `os-keychain` +- [x] The `env` backend warns at startup that it is unsuitable outside development +- [x] `encrypted-file` refuses to start on a world-readable keystore +- [x] Decrypted keys are held as `byte[]`/`char[]` and zeroed on shutdown, never as `String` +- [x] `nostr_list_identities` returns aliases and public keys only +- [x] `IdentitySummary` has no field capable of holding a private key +- [x] A test walks every registered tool and resource and asserts no response or error can + contain a private key, and no input schema accepts one +- [x] The startup banner prints public keys only +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/04-key-admin-cli-and-single-identity-mode.md b/.scratch/nostr-java-mcp/issues/04-key-admin-cli-and-single-identity-mode.md new file mode 100644 index 00000000..60e03999 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/04-key-admin-cli-and-single-identity-mode.md @@ -0,0 +1,30 @@ +# 04: Key-admin CLI and single-identity mode + +**What to build:** A human can create and manage keys from the command line, and can bind a +server process to exactly one identity so an agent cannot post as the wrong account. + +These belong together because they are two halves of one idea: a bound server operates a key +and does not administer a keystore, so binding presupposes some other way to create keys. The +CLI is that way, and it keeps key administration out of every agent's reach entirely. + +Single-identity mode removes the wrong-account risk rather than guarding it. With the process +bound, the `identity` argument disappears from every signing tool's schema: there is nothing to +name, so nothing to name wrongly. + +**Blocked by:** 03 (`IdentityVault` and keystore backends). + +**Status:** ready-for-agent + +- [x] The same jar runs as an MCP server and as a CLI offering `keygen`, `import`, `list` and + `remove` +- [x] `nostr.mcp.identity` binds the process to one alias +- [x] A bound process unlocks only that alias, leaving other entries undecrypted and absent + from the heap +- [x] A bound process omits the `identity` argument from signing tool schemas entirely +- [x] A bound process registers no identity lifecycle tools +- [x] `nostr_list_identities` on a bound server returns the single bound identity +- [x] A server started with an empty keystore refuses to start and says how to create an + identity, rather than generating one silently +- [x] Golden-file tests pin the tool list separately for bound and unbound modes, so + unregistration is asserted rather than assumed +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/05-read-tools.md b/.scratch/nostr-java-mcp/issues/05-read-tools.md new file mode 100644 index 00000000..631e8e40 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/05-read-tools.md @@ -0,0 +1,27 @@ +# 05: Read tools for querying, profiles and relay metadata + +**What to build:** An agent can answer questions about Nostr without being able to change +anything: fetch events matching a filter, look up someone's profile, and read a relay's +capabilities. + +This is the whole read path, and it is where the module's argument conventions are established, +so later tools inherit them rather than reinventing them. Public keys accept hex or `npub`, +event ids accept hex or `note`/`nevent`, and timestamps accept ISO-8601 or relative expressions +like `24h`. Decoding lives in one place; tools never parse bech32 inline. + +A query is bounded: `limits.max-events-per-query` and `query-timeout` exist because a relay +serves one request at a time, so an unbounded query can stall every other tool call. + +**Blocked by:** 02 (Module skeleton with MCP stdio transport). + +**Status:** ready-for-agent + +- [x] `nostr_query_events` runs a one-shot query and returns matching events +- [x] `nostr_get_profile` fetches and decodes kind-0 metadata, accepting a pubkey or a NIP-05 + address +- [x] `nostr_relay_info` returns a relay's NIP-11 document +- [x] `NostrIdentifier` centralises hex/bech32 decoding for keys and event ids +- [x] Relative timestamps are normalised to Unix seconds at the boundary +- [x] Queries respect the configured event limit and timeout +- [x] Errors use the stable codes from the spec rather than stack traces +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/06-write-guard-and-publish-tools.md b/.scratch/nostr-java-mcp/issues/06-write-guard-and-publish-tools.md new file mode 100644 index 00000000..f72fa7c4 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/06-write-guard-and-publish-tools.md @@ -0,0 +1,31 @@ +# 06: `WriteGuard` and the publish tools + +**What to build:** An agent can publish to Nostr, and a hallucinated post is a no-op rather +than a public, permanent mistake. + +Publishing is irreversible: an event accepted by a relay cannot be reliably deleted, since +NIP-09 is advisory. So `write-policy: confirm` is the default, and a write tool returns a +preview of the signed event with a `confirmationToken` that the agent must present on a second +call. `deny` unregisters the write tools altogether, because a tool an agent cannot see is a +tool it cannot misuse. + +Results follow the SDK rather than inventing a convention: a `PublishResult` with any +acceptance is a success carrying the per-relay list, and only `NoRelayAcceptedException` is an +error. Reporting partial success as failure would push agents to retry writes that already +landed. + +**Blocked by:** 03 (`IdentityVault` and keystore backends), 05 (Read tools). + +**Status:** ready-for-agent + +- [x] `nostr_publish_note` publishes a kind-1 note, signed by the configured identity +- [x] `nostr_publish_event` publishes an arbitrary kind as the escape hatch +- [x] `nostr_update_profile` publishes kind-0 metadata +- [x] Under `confirm`, the first call previews and the second call with the token publishes +- [x] Confirmation tokens stay valid until used or until restart; they do not expire on a timer +- [x] Under `deny`, no write tool is registered at all +- [x] Partial success returns the per-relay list; only a total failure is an error +- [x] Rate limits are enforced per identity and per relay +- [x] Every write is logged with event id, kind, identity pubkey and target relays +- [x] A golden-file test pins the tool list under `write-policy: deny` +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/07-identity-lifecycle-tools.md b/.scratch/nostr-java-mcp/issues/07-identity-lifecycle-tools.md new file mode 100644 index 00000000..f77f0316 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/07-identity-lifecycle-tools.md @@ -0,0 +1,39 @@ +# 07: Identity lifecycle tools behind `identity-policy` + +**What to build:** An agent asked to "make me a throwaway account for this project" can do it, +and an agent that hallucinates "remove that key" cannot destroy an account. + +An identity is the user's Nostr account: creating one is cheap, losing one is unrecoverable, +and exposing one is irreversible. The tools are shaped around that asymmetry rather than +treated uniformly. Create, rename and set-default are freely allowed. Export and remove are +two-step guarded. Import never accepts key material as an argument at all. + +That last point is the one that matters most. If `nostr_import_identity` took an `nsec`, the +key would pass through the model's context, land in the host's conversation log, and very +likely reach a third-party inference API. Instead `source` names *where the server should read +the key from itself*: a file it then offers to shred, its own environment, or a terminal prompt +the agent cannot see. + +**Blocked by:** 04 (Key-admin CLI and single-identity mode), 06 (`WriteGuard` and the publish +tools). It waits on 06 because identity mutations reuse that ticket's two-step confirmation +rather than introducing a second confirmation concept. + +**Status:** ready-for-agent + +- [x] `nostr_create_identity` generates a key in the keystore and returns only alias, public + key and npub +- [x] `nostr_import_identity` accepts no key material as an argument; `source` names a file, + an environment variable, or a prompt the agent cannot observe +- [x] `nostr_rename_identity` and `nostr_set_default_identity` change aliases and defaults +- [x] `nostr_export_identity_backup` writes an encrypted file and returns the path only, never + the contents +- [x] `nostr_remove_identity` is two-step, and refuses without a prior backup unless + `acknowledgeNoBackup` is set +- [x] Removal zeroes the in-memory key, removes the entry, and closes anything bound to it +- [x] `identity-policy` governs these tools separately from `write-policy`, defaulting to the + more restrictive of the two; `write-policy: deny` implies no mutation +- [x] Signing fails with `IDENTITY_AMBIGUOUS` when several identities exist and no default is + set, rather than guessing +- [x] Aliases are validated against `[a-z0-9-]{1,32}`, since they appear in resource URIs +- [x] Every keystore mutation is logged with alias and public key +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/08-subscriptions.md b/.scratch/nostr-java-mcp/issues/08-subscriptions.md new file mode 100644 index 00000000..cb0751a8 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/08-subscriptions.md @@ -0,0 +1,32 @@ +# 08: Long-lived subscriptions with buffering and resources + +**What to build:** An agent asked to "watch my mentions" receives events that arrive after the +call returns, and is told when it missed some. + +MCP cannot push into a tool result, so a subscription becomes a stateful server resource: the +tool opens it and returns an id, events land in a bounded buffer, and the agent drains them. +The buffer is not de-duplication, which the SDK already does across relays; it exists because +events arriving between polls must be held somewhere. + +`RelayPool.subscribe` is **asynchronous**: it returns before any stored event or the +end-of-backlog signal arrives, verified against a live relay in `McpSpecAssumptionsIT`. So the +tool must not pretend history is ready. It reports `backlogDrained` so an agent can tell +"nothing matched yet" from "still replaying", and a tool that blocked until the backlog drained +would stall on any unresponsive relay. + +**Blocked by:** 05 (Read tools). + +**Status:** ready-for-agent + +- [x] `nostr_subscribe` opens a subscription across every relay and returns an id plus + `backlogDrained: false` +- [x] Events land in a bounded ring buffer; overflow drops the oldest and increments a + monotonic `droppedCount` so the agent knows it missed data +- [x] `nostr_read_subscription` drains what it returns, so repeated calls yield only new events +- [x] `nostr_list_subscriptions` reports filters, buffer depth, drop count and relay health +- [x] `nostr_unsubscribe` closes a subscription and frees its buffer +- [x] Each subscription is exposed as `nostr://subscription/{id}` with update notifications +- [x] An idle TTL reaps abandoned subscriptions, and the live total is capped +- [x] A relay dropping mid-stream is surfaced rather than silently reducing coverage +- [x] Buffers tolerate a duplicate, since SDK de-duplication is windowed +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/09-social-tools.md b/.scratch/nostr-java-mcp/issues/09-social-tools.md new file mode 100644 index 00000000..c755973e --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/09-social-tools.md @@ -0,0 +1,33 @@ +# 09: Threads, contacts and direct messages + +**What to build:** An agent can follow a conversation, read who someone follows, and send a +private message that only its recipient can read. + +Direct messages need no SDK work: NIP-17 gift wrapping shipped in 2.1.0 and delivery to a +recipient's own relays in 2.2.0, so `nostr_send_direct_message` is a thin adapter over +`NostrClient.sendDirectMessage`. Two behaviours must be surfaced rather than hidden, both +verified against a live relay: + +- A recipient who published no kind-10050 relay list is `UNREACHABLE`, because NIP-17 forbids + sending to them. The message genuinely did not go, and the user must be told who missed it. +- Every conversation includes the sender, so a one-recipient send reports **two** outcomes. A + sender without their own relay list sees their archival copy come back `UNREACHABLE` while + the recipient is `DELIVERED`. Reported as "1 of 2 delivered", that would tell a user their + message failed when it arrived perfectly well. + +**Blocked by:** 01 (`ContactList` type over kind-3), 06 (`WriteGuard` and the publish tools), +08 (Long-lived subscriptions). + +**Status:** ready-for-agent + +- [x] `nostr_fetch_thread` resolves a note and its replies per NIP-10 +- [x] `nostr_get_contacts` reads a kind-3 list through the SDK's `ContactList` type +- [x] `nostr_send_direct_message` delivers to each recipient's own relays and reports per + recipient +- [x] A recipient without a relay list is reported `UNREACHABLE`, not silently skipped +- [x] The sender's own copy is reported separately from the recipients, so it is never counted + as a failed delivery +- [x] `nostr_read_direct_messages` unwraps gift wraps addressed to an identity +- [x] DM decryption is opt-in per identity, since it exposes private correspondence to the model +- [x] NIP-04 is not exposed at all +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/10-http-transport.md b/.scratch/nostr-java-mcp/issues/10-http-transport.md new file mode 100644 index 00000000..d2fcca0a --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/10-http-transport.md @@ -0,0 +1,29 @@ +# 10: HTTP transport with per-session identity binding + +**What to build:** A hosted deployment can reach the server over HTTP rather than stdio, with +each session bound to one identity, and cannot accidentally expose it to the network. + +The transport carries **no authentication of its own** in v1. That is a deliberate deferral: +a token in a config file protects little, and deployments that genuinely need remote access +need a reverse proxy with real credentials in front regardless. A deferral is only safe if the +constraint replacing it is visible, so the default binding is loopback and a non-loopback bind +warns at startup, the same way the `env` keystore backend warns. + +Per-session binding is the middle ground between one shared server and one process per +identity: it guards against agent confusion within one heap, not against a compromised process. +That weaker guarantee must be stated where a deployer reads it, not implied. + +**Blocked by:** 07 (Identity lifecycle tools). + +**Status:** ready-for-agent + +- [x] The server runs over streamable HTTP as well as stdio, selected by `transport` +- [x] `bind-address` defaults to `127.0.0.1` +- [x] A non-loopback bind logs a warning at startup naming the risk +- [~] A session can be bound to one identity, filtering the tool surface as single-identity + mode does — deferred: the streamable transport builds one server for all sessions, so + per-session filtering needs a session-scoped tool surface the SDK does not yet expose. + Process-level binding (ticket 04) remains the supported and stronger isolation. +- [x] Documentation states plainly that the transport is unauthenticated and belongs behind a + proxy if exposed +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/11-packaging.md b/.scratch/nostr-java-mcp/issues/11-packaging.md new file mode 100644 index 00000000..f7291ee8 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/11-packaging.md @@ -0,0 +1,25 @@ +# 11: Container packaging and the bound-container pattern + +**What to build:** An operator can run the server from a container, including one container per +identity, without hand-assembling the configuration or accidentally publishing an +unauthenticated port to the network. + +The container is where the HTTP transport's lack of authentication becomes dangerous, because +publishing a port reaches every interface by default. The compose file therefore binds to the +host loopback explicitly rather than relying on the server's own default. + +It is also where the keystore default changes: `os-keychain` has nothing to talk to inside a +container, so the compose file selects `encrypted-file` explicitly. + +**Blocked by:** 10 (HTTP transport with per-session identity binding). + +**Status:** ready-for-agent + +- [x] A `Dockerfile` builds on a distroless JRE 21 base and runs as a non-root user +- [x] `docker-compose.yml` runs the server in HTTP mode alongside the test relay container +- [x] Its port mapping binds to the host loopback (`127.0.0.1:PORT:PORT`), not every interface +- [x] It sets `keystore.type: encrypted-file` explicitly, with the keystore mounted read-only + and the passphrase supplied as a secret +- [x] A profile demonstrates one bound container per identity, each reading only its own key +- [x] `docker-compose build` runs in CI, per repo convention +- [x] `mvn -q verify` passes diff --git a/.scratch/nostr-java-mcp/issues/12-prompts-and-host-documentation.md b/.scratch/nostr-java-mcp/issues/12-prompts-and-host-documentation.md new file mode 100644 index 00000000..7f5919b2 --- /dev/null +++ b/.scratch/nostr-java-mcp/issues/12-prompts-and-host-documentation.md @@ -0,0 +1,28 @@ +# 12: Guided prompts and the MCP host how-to + +**What to build:** Someone who has never used this server can wire it into their MCP host and +get useful work out of it, and the agent is taught how to sequence the tools rather than +guessing. + +A tool surface without guidance makes an agent explore by trial and error, which on a public, +irreversible medium is the wrong way to learn. Prompts encode the sequences that work. + +The documentation leads with single-identity mode, because it is the safer default, matches how +MCP hosts are configured anyway, and removes the wrong-account risk rather than guarding it. +The multi-identity server is the advanced case, for the cross-identity queries a bound server +cannot answer. + +**Blocked by:** 09 (Threads, contacts and direct messages), 11 (Container packaging). + +**Status:** ready-for-agent + +- [x] Prompts ship for `compose-note`, `catch-up-feed` and `watch-mentions` +- [x] Resources expose `nostr://identity/{alias}` and `nostr://relay/{name}` so an agent can + read context without a tool call +- [x] A how-to under `docs/howto` covers wiring the server into an MCP host, leading with + single-identity mode and presenting multi-identity as the advanced case +- [x] It is linked from `docs/README.md` per the repo's Diátaxis convention +- [x] The inherited SDK limits are documented where a user meets them: per-relay throughput, + windowed de-duplication, and the unauthenticated HTTP transport +- [x] `CHANGELOG.md` records the new module +- [x] `mvn -q verify` passes diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d57fb2..f8930366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,76 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [2.3.1] - 2026-08-31 + +### Removed +- `CONTRIBUTING.md`. It documented the pre-2.0 architecture, telling contributors to add a + per-NIP facade extending `EventNostr` and to update a NIP compliance matrix; none of those + exist. Its still-accurate parts, the commit and pull request conventions, moved into + [the codebase overview](docs/CODEBASE_OVERVIEW.md), which also no longer claims pull requests + target `develop` when the repository's default branch is `main`. + +### Added +- `DocumentationAccuracyTest`, which holds the documentation to the same standard as the code: it fails when a guide names a type or method that does not exist, a link points at nothing, an install snippet quotes a version other than the one being built, or a page is unreachable from the index. Documentation rots silently because nothing breaks when it does. +- Tool coverage is now measured on two axes and enforced: every tool is exercised against a real relay, and every tool is reached by a real model from a plain-language request. Both started as gaps: three tools had never touched a relay in a Maven test, and only four of the twenty-two had ever been offered to a model. +- `UntestedToolsIT` and `ToolCoverageTest`. Eight of the twenty-two tools had only ever had their registration checked, never a call; the coverage test measures that and fails when any tool goes uncalled, so the gap cannot reopen. +- `OllamaAgentIT`, which drives the MCP tool surface with a real local language model through Testcontainers. Every other test asks whether the tools work; this asks whether a model can use them, which a correct-but-unusable tool would fail. It checks that a model picks the right tool unprompted, tells querying from subscribing, and reads a publish preview as "not yet published" rather than as success. Excluded from the ordinary build and run with `-Dexcluded.it.groups= -Dgroups=model-driven`. + +## [2.3.0] - 2026-08-30 + +### Added +- MCP guided prompts (`compose-note`, `catch-up-feed`, `watch-mentions`) and context resources (`nostr://identity/{alias}`, `nostr://relay/{name}`). The prompts encode the sequences models get wrong, such as treating a publish preview as the publication or reading a still-replaying subscription as an empty one, since a tool surface with no guidance makes an agent learn by trial and error on a permanent public medium. +- MCP packaging: a runnable jar (`-runnable` classifier), a distroless `Dockerfile` running as a non-root user, and a `docker-compose.yml` that runs the server beside a local relay and demonstrates one bound container per identity. Every published port binds the host loopback explicitly, because Docker otherwise publishes to all interfaces and would bypass the server's own loopback default. +- MCP HTTP transport, selected with `nostr.mcp.transport=http`, for hosted deployments where the MCP host does not launch the process itself. It has no authentication of its own, so it binds `127.0.0.1` by default and warns at startup when configured otherwise; exposing it beyond the machine needs a reverse proxy with real credentials in front. Documented in a new how-to guide, [Run the Nostr MCP server](docs/howto/run-the-mcp-server.md), whose settings table is checked against the code by a test. +- MCP social tools: `nostr_fetch_thread`, `nostr_get_contacts`, `nostr_send_direct_message` and `nostr_read_direct_messages`, plus `replyTo` and `mentions` on note publishing so a reply is threaded rather than detached. Direct messages use NIP-17 gift wrapping, so relays see neither the correspondents nor the content, and NIP-04 is not exposed at all. Two delivery facts are surfaced rather than smoothed over: a recipient who published no kind-10050 relay list is reported unreachable by name, and the sender's own archival copy is reported separately from the recipients so it is never counted as a failed delivery. Decrypting incoming messages is opt-in per identity via `nostr.mcp.dm.decrypt-for`. +- MCP subscriptions: `nostr_subscribe`, `nostr_read_subscription`, `nostr_list_subscriptions` and `nostr_unsubscribe`, so an agent can watch for events that have not happened yet. MCP cannot push into a tool result, so events land in a bounded per-subscription buffer that the agent drains; overflow drops the oldest and reports a monotonic count, because an agent told nothing about a gap will summarise a partial feed as the whole one. Subscriptions are also exposed as `nostr://subscription/{id}` resources with update notifications, are capped in number, and are reaped once nobody has read them, since an agent's session can end without the server being told. +- MCP identity lifecycle tools, shaped around what can and cannot be undone. Creating, renaming and choosing a default are freely allowed; exporting a backup and removing a key are guarded. `nostr_import_identity` accepts no key material at all: `source` names a file, an environment variable or a terminal prompt that the server reads for itself, so a key never passes through the model's context. `nostr_remove_identity` confirms in two steps and refuses outright unless a backup exists or the caller explicitly acknowledges there is none. `identity-policy` governs these separately from `write-policy` and is capped by it, so a read-only server cannot mutate the keystore either. +- MCP publishing: `nostr_publish_note`, `nostr_publish_event` and `nostr_update_profile`, all passing through one `WriteGuard`. `write-policy: confirm` is the default, so a write is previewed and published only when the agent returns the token it was given, turning a hallucinated post into a no-op; `deny` registers no write tool at all, and `allow` publishes directly. Writes are rate-limited per identity, every write is logged with event id, kind, signing key and target relays, and a partial success reports the per-relay outcome rather than an error, so an agent is never told to retry a write that already landed. +- MCP read tools: `nostr_query_events`, `nostr_get_profile` (by public key or NIP-05 address) and `nostr_relay_info`. Shared argument conventions live in one place: `NostrIdentifier` accepts hex or bech32 for keys and event ids, and `TimeArgument` normalises relative ages such as `24h`, ISO-8601 timestamps and bare dates to Unix seconds. Queries are bounded by `limits.max-events-per-query` and `limits.query-timeout`, and a truncated or timed-out answer says so rather than passing as complete. +- MCP single-identity mode and key-admin CLI. `nostr.mcp.identity` binds a server process to one alias: only that entry is decrypted, so another identity's key is absent from the heap rather than merely refused, and a bound server registers no keystore-mutating tools. The same jar offers `keygen`, `import`, `list` and `remove` on the command line, keeping key administration in the hands of the human who set the servers up rather than any agent. +- MCP identity vault: `nostr_list_identities` tool with os-keychain, encrypted-file, and environment key sources; private keys never appear on the tool surface and are wiped on shutdown. +- **`nostr-java-mcp`**, a new module exposing the SDK as a Model Context Protocol server so an LLM agent can use Nostr without Nostr-specific code. This first slice ships the stdio transport an MCP host launches directly, a tool registry where adding a capability means adding a class rather than editing a dispatcher, and `nostr_list_relays` reporting each configured relay's connection state. It adapts `nostr-java-api` rather than reaching past it, so relay pooling, result aggregation and de-duplication stay the SDK's concern. +- `ContactList` and `Contact`, modelling a NIP-02 follow list. Each entry keeps the three parts the specification defines, the followed key plus an optional relay hint and petname, rather than the key alone: the hint is how a client finds someone it has never seen, and the petname is how it shows a readable name without a global registry. Entries keep their order, since NIP-02 asks that new follows be appended so a list reads chronologically, and a duplicated key keeps its first entry. Malformed entries are discarded rather than making a whole list unreadable. Groundwork for the planned `nostr-java-mcp` module, whose contacts tool had nothing to call. + +### Fixed +- Documentation defects found by testing the docs rather than reading them: the API reference taught `BaseMessage.read(json)`, a method that has never existed; five install snippets used ``, which Maven reads as an empty version; the getting-started guide recommended `nostr-java-client` while the README recommended `nostr-java-api`; and `MIGRATION.md` linked to a `nostr-java-examples` module that does not exist. +- `-DnoDocker=true`, documented in five places and passed by `scripts/release.sh --no-docker`, set a property nothing reads, so it never skipped the container-backed tests. Both the docs and the release script now use `-Pno-docker`, which does. +- `nostr_relay_info` could not read any real relay's NIP-11 document. Java's HTTP client offers an HTTP/2 upgrade, and a relay serves that document from the same host and port as its websocket endpoint, so it read the upgrade headers as a botched websocket handshake and answered `400 Failed to create websocket`. The client is now pinned to HTTP/1.1. +- `nostr_relay_info` given a name that was neither configured nor a URI failed with an `IllegalArgumentException` about an undefined scheme, instead of a stable code naming the known relays. +- A server bound to a single identity still offered an `identity` argument on its five signing tools, so the argument binding exists to remove was merely redundant rather than absent. An argument with exactly one acceptable value invites a model to pass a different one, turning an impossible mistake back into a possible one. Found by driving the shipped jar as an MCP host does. +- Settings whose names contain a hyphen, including `write-policy`, `bind-address` and every `limits.*` entry, could not be set from the environment: the name was translated to an environment variable by replacing dots but not hyphens, producing names such as `NOSTR_MCP_BIND-ADDRESS` that no shell can set. Hyphens are now translated too, which is what makes the container configurable at all. +- Inbound relay frames are delivered to each listener in the order the relay sent them. Every frame was previously dispatched on a freshly started virtual thread, so an `EOSE` could overtake the stored events it follows; any query ending on that signal then returned a partial answer indistinguishable from the relay holding less data. Observed against a real relay: asking for three stored events, the end-of-backlog signal arrived with only one or two delivered, varying run to run. + +## [2.2.0] - 2026-08-30 + +### Added +- **`nostr-java-api`**, a new module and the intended entry point for applications. `NostrClient` ties an identity to a set of relays and covers what every client would otherwise write itself: signing and publishing in one call, subscribing across relays, and sending NIP-17 private direct messages. Events remain `GenericEvent` and the relay pool stays reachable, so the facade adds capability without walling anything off. Ownership follows construction: a pool the client built is closed with it, a pool passed in is left to its owner ([ADR-0001](docs/decisions/0001-introduce-nostr-java-api-module.md)). +- `RelayListLookup`, the first real implementation of `DirectMessageRelayLookup`, resolving kind-10050 lists through the relay pool. `nostr-java-identity` declared this need but could not meet it without acquiring a transport. +- `DirectMessagePublisher`, which performs the delivery plan `nostr-java-identity` can only produce: each gift wrap goes to its own recipient's relays, and every participant's outcome is reported, since a group message that reaches three of four people has partly succeeded. +- Runtime relay pool membership. Relays join and leave a running pool, and one borrowed to reach a message recipient is released afterwards by counting holders, so overlapping deliveries do not cut each other off. +- Multi-relay subscriptions. `RelayPool.subscribe` registers one filter with every relay and presents the result as a single stream: each event is delivered once however many relays hold it, already parsed as a `GenericEvent`, with de-duplication through a bounded window so a long-lived firehose cannot grow its memory without limit. The per-relay `EOSE` frames are aggregated into one end-of-backlog signal, emitted when every relay has reported or a timeout expires, so an unresponsive relay cannot leave an application loading forever. A relay that drops mid-stream is reported to the caller and re-subscribed from its stored filter when it reconnects, and a malformed payload is reported without ending the subscription ([ADR-0004](docs/decisions/0004-pool-concurrency-and-subscription-lifecycle.md), [ADR-0005](docs/decisions/0005-pool-membership-eose-and-ownership.md)). +- `RelayPool` now serialises operations per relay, so several threads can publish at once without colliding with `NostrRelayClient`'s one-request-in-flight limit. Locking is per relay rather than pool-wide, so a slow relay delays only its own queue while fan-out across relays stays concurrent. Each relay's `ConnectionState` is observable, and downed relays are retried on a schedule the pool owns, so a relay that recovers rejoins without an application restart. Relays that drop after connecting are reconnected too, not only those that failed at startup ([ADR-0004](docs/decisions/0004-pool-concurrency-and-subscription-lifecycle.md)). +- `RelayPool`, which publishes one event to many relays at once and reports what each of them did. `PublishResult` records, per relay, acceptance, rejection with the relay's verbatim reason, a timeout, or unreachability, so partial delivery is visible instead of collapsed into a boolean. A publish that no relay accepted throws `NoRelayAcceptedException` carrying the same result, because an event that reached nobody must not be mistaken for a published one. The pool is best-effort on construction, so an unreachable relay cannot stop an application starting, and one timeout bounds the whole publish rather than each relay in turn ([ADR-0002](docs/decisions/0002-multi-relay-failure-semantics.md)). +- `RelayConnection` and `RelayConnectionFactory`, the seam between relay coordination and relay transport. `NostrRelayClient` implements the interface, which exposes only what code coordinating several relays needs (identify, send, subscribe, observe state, close) rather than mirroring the client's full surface. Behaviour is unchanged; the seam exists so that multi-relay work can be tested against scripted relay behaviour instead of live sockets, and so modules above it need not depend on Spring. Groundwork for the planned `nostr-java-api` module ([ADR-0001](docs/decisions/0001-introduce-nostr-java-api-module.md)). + +### Fixed +- Stored events could arrive after the end-of-backlog signal that is supposed to follow them. The transport dispatches each inbound frame on its own thread, so an `EOSE` could overtake the events it trails and tell an application its backlog was drained while those events were still arriving. Subscriptions now deliver frames in the order the relay sent them. Found by testing against a live relay, where a lookup intermittently reported no result for a list the relay was serving. + +## [2.1.0] - 2026-08-30 + +### Added +- NIP-17 private direct messages, with the NIP-59 gift wrapping they build on. `Nip17DirectMessageService` composes a `ChatMessage` into one gift wrap per participant and reads incoming wraps back; `Nip59GiftWrapper` implements the generic three-layer envelope (unsigned kind-14 rumor, kind-13 seal signed by the real author, kind-1059 wrap signed by a single-use key) and is usable for any event kind, not just messages. Unlike NIP-04, which hides only the message text, this conceals the correspondents, the timing, and the message count. +- `Rumor`, the unsigned event NIP-59 wraps. It is deliberately not `ISignable` and holds no signature field, so the deniability the scheme depends on is enforced by the type system rather than by convention. +- `DirectMessageRelayList` (kind 10050) and `DirectMessageService.planDelivery`, which pairs each participant's copy with the relays that participant nominated. NIP-17 permits delivery only to those relays and forbids sending at all to someone who published no list; an unreachable recipient is reported explicitly rather than omitted, so a message cannot go half-delivered unnoticed. +- `GenericEvent.update(long createdAt)`, which recomputes an event's id without consulting the clock. The existing no-arg `update()` delegates to it, so no call site changes behaviour. +- [How to send private direct messages](docs/howto/private-direct-messages.md). + +### Fixed +- `GenericEvent.getByteArraySupplier()` no longer resets `created_at` to the current time. It calls `update()`, and so ran during `Identity.sign()` — meaning **signing silently moved an event in time**. Any deliberately chosen timestamp was discarded moments after being set, which made NIP-59's randomised past timestamps impossible to produce and defeated the timing-correlation defence they exist to provide, while the calling code read as though the protection were present. An event that already carries a creation time now keeps it. + +### Deprecated +- NIP-04 encrypted direct messages (`EncryptedDirectMessage`, `MessageCipher04`). They remain functional for reading existing conversations and interoperating with clients that send nothing else, but new code should use NIP-17. Nothing is removed in this release. + ## [2.0.8] - 2026-08-22 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4571e6f1..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,202 +0,0 @@ -# Contributing to nostr-java - -Thank you for contributing to nostr-java! This project implements the Nostr protocol. For a complete index of current Nostr Implementation Possibilities (NIPs), see [AGENTS.md](AGENTS.md). - -## Table of Contents - -- [Getting Started](#getting-started) -- [Development Guidelines](#development-guidelines) -- [Coding Standards](#coding-standards) -- [Architecture Guidelines](#architecture-guidelines) -- [Adding New NIPs](#adding-new-nips) -- [Testing Requirements](#testing-requirements) -- [Commit Guidelines](#commit-guidelines) -- [Pull Request Guidelines](#pull-request-guidelines) - -## Getting Started - -### Prerequisites - -- **Java 21+** - Required for building and running the project -- **Maven 3.8+** - For dependency management and building -- **Git** - For version control - -### Setup - -1. Fork the repository on GitHub -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/nostr-java.git` -3. Add upstream remote: `git remote add upstream https://github.com/tcheeric/nostr-java.git` -4. Build: `mvn clean install` -5. Run tests: `mvn test` - -## Development Guidelines - -- All changes must include unit tests and update relevant documentation. -- Use clear, descriptive names and remove unused imports. -- Prefer readable, maintainable code over clever shortcuts. -- Run `mvn -q verify` from the repository root before committing. -- Submit pull requests against the `main` branch. - -### Before Submitting - -✅ All tests pass: `mvn test` -✅ Code compiles: `mvn clean install` -✅ JavaDoc complete for public APIs -✅ Branch up-to-date with latest `main` - -## Coding Standards - -This project follows **Clean Code** principles. Key guidelines: - -- **Single Responsibility Principle** - Each class should have one reason to change -- **DRY (Don't Repeat Yourself)** - Avoid code duplication -- **Meaningful Names** - Use descriptive, intention-revealing names -- **Small Functions** - Functions should do one thing well - -### Naming Conventions - -**Classes:** -- Entities: Noun names (e.g., `GenericEvent`, `UserProfile`) -- Builders: End with `Builder` (e.g., `NIP01EventBuilder`) -- Factories: End with `Factory` (e.g., `NIP01TagFactory`) -- Validators: End with `Validator` (e.g., `EventValidator`) -- Serializers: End with `Serializer` (e.g., `EventSerializer`) -- NIP implementations: Use `NIPxx` format (e.g., `NIP01`, `NIP57`) - -**Methods:** -- Getters: `getKind()`, `getPubKey()` -- Setters: `setContent()`, `setTags()` -- Booleans: `isEphemeral()`, `hasTag()` -- Factory methods: `createEventTag()`, `buildTextNote()` - -**Variables:** -- Use camelCase (e.g., `eventId`, `publicKey`) -- Constants: UPPER_SNAKE_CASE (e.g., `REPLACEABLE_KIND_MIN`) - -### Code Formatting - -- **Indentation:** 2 spaces (no tabs) -- **Line length:** Max 100 characters (soft limit) -- **Use Lombok:** `@Data`, `@Builder`, `@NonNull`, `@Slf4j` -- **Remove unused imports** - -## Architecture Guidelines - -This project follows **Clean Architecture**. See [docs/explanation/architecture.md](docs/explanation/architecture.md) for details. - -### Module Organization - -``` -nostr-java/ -├── nostr-java-base/ # Domain entities -├── nostr-java-crypto/ # Cryptography -├── nostr-java-event/ # Event implementations -├── nostr-java-api/ # NIP facades -├── nostr-java-client/ # Relay clients -``` - -### Design Patterns - -- **Facade:** NIP implementation classes (e.g., NIP01, NIP57) -- **Builder:** Complex object construction -- **Factory:** Creating instances (tags, messages) -- **Template Method:** Validation with overrideable steps -- **Utility:** Stateless helper classes - -## Adding New NIPs - -### Quick Guide - -1. **Read the NIP spec** at https://github.com/nostr-protocol/nips -2. **Create event class** (if needed) in `nostr-java-event` -3. **Create facade** in `nostr-java-api` -4. **Write tests** (minimum 80% coverage) -5. **Add JavaDoc** with usage examples -6. **Update README** NIP compliance matrix - -### Example Structure - -```java -/** - * Facade for NIP-XX (Feature Name). - * - *

Usage Example: - *

{@code
- * NIPxx nip = new NIPxx(identity);
- * nip.createEvent("content")
- *    .sign()
- *    .send(relayUri);
- * }
- * - * @see NIP-XX - * @since 0.x.0 - */ -public class NIPxx extends EventNostr { - // Implementation -} -``` - -See [docs/explanation/architecture.md](docs/explanation/architecture.md) for detailed step-by-step guide. - -## Testing Requirements - -- **Minimum coverage:** 80% for new code -- **Test all edge cases:** null values, empty strings, invalid inputs -- **Use descriptive test names** or `@DisplayName` - -### Client/Handler tests - -- See `nostr-java-api/src/test/java/nostr/api/client/README.md` for structure and naming. -- Naming conventions: - - `NostrSpringWebSocketClient*` for high‑level client behavior - - `WebSocketHandler*` for internal handler semantics (send/close/request) - - `NostrRequestDispatcher*` and `NostrSubscriptionManager*` for dispatcher/manager lifecycles -- Use `nostr.api.TestHandlerFactory` to construct `WebSocketClientHandler` from tests outside `nostr.api`. - -### Client module tests - -- See `nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md` for an overview of the Spring WebSocket client test suite (retry/subscribe/timeout behavior). - -### Test Example - -```java -@Test -@DisplayName("Validator should reject negative kind values") -void testValidateKindRejectsNegative() { - Integer invalidKind = -1; - - AssertionError error = assertThrows( - AssertionError.class, - () -> EventValidator.validateKind(invalidKind) - ); - assertTrue(error.getMessage().contains("non-negative")); -} -``` - -## Commit Guidelines - -- All commit messages must follow the requirements in [`commit_instructions.md`](commit_instructions.md). -- PR titles and commit messages must use the `type(scope): description` format and allowed types. -- See the commit instructions file for details and examples. - -### Allowed Commit Types - -`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` - -### Good Examples - -- `feat(auth): add magic-link login` -- `fix(api): handle 429 with exponential backoff` -- `docs(readme): clarify local setup` -- `refactor(search): extract ranking pipeline` - -### Issue Linking - -- In the PR body, add: `Closes #123` (or `Fixes ABC-456` for Jira). GitHub will auto-close on merge. - -## Pull Request Guidelines - -- Summaries in pull requests must cite file paths and include testing output. -- Open pull requests using the template at `.github/pull_request_template.md` and complete every section. - -By following these conventions, contributors help keep the codebase maintainable and aligned with the Nostr specifications. diff --git a/README.md b/README.md index e069f1bd..da75b785 100644 --- a/README.md +++ b/README.md @@ -6,110 +6,121 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Qodana](https://github.com/tcheeric/nostr-java/actions/workflows/qodana_code_quality.yml/badge.svg)](https://github.com/tcheeric/nostr-java/actions/workflows/qodana_code_quality.yml) -`nostr-java` is a Java SDK for the [Nostr](https://github.com/nostr-protocol/nips) protocol. It provides utilities for creating, signing and publishing Nostr events to relays. +A Java SDK for the [Nostr protocol](https://github.com/nostr-protocol/nips). Create, sign and +publish events; talk to many relays at once; send encrypted direct messages; and expose all of +it to an LLM agent through a Model Context Protocol server. -## Requirements -- Maven -- Java 21+ +Requires **Java 21** and Maven. -See [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) for installation and usage instructions. - -## Quick Start +## Quick start ```java Identity identity = Identity.generateRandomIdentity(); -GenericEvent event = GenericEvent.builder() - .pubKey(identity.getPublicKey()) - .kind(Kinds.TEXT_NOTE) - .content("Hello Nostr!") - .tags(List.of(GenericTag.of("t", "nostr-java"))) - .build(); +try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz", "wss://nos.lol") + .build()) { -identity.sign(event); + PublishResult result = nostr.publishTextNote("Hello Nostr!"); -try (NostrRelayClient client = new NostrRelayClient("wss://relay.398ja.xyz")) { - client.send(new EventMessage(event)); + System.out.println("stored by " + result.getAcceptingRelays()); + result.getFailures().forEach(failure -> + System.out.println("refused by " + failure.relayUri())); } ``` -## Module Architecture +Publishing reports what each relay did rather than collapsing the answer to a boolean, and +throws `NoRelayAcceptedException` only when no relay accepted the event at all. A note that +reached three relays out of five has been published, and the caller needs to know which two +missed it rather than being told the whole thing failed. See +[publishing across many relays](docs/howto/multi-relay-publishing.md). -4 modules with a strict dependency chain: +Installation, including Gradle and BOM coordinates, is in +[Getting started](docs/GETTING_STARTED.md). -``` -nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client +## Give an LLM agent access to Nostr + +`nostr-java-mcp` runs the SDK as a Model Context Protocol server, so an agent in Claude Desktop +or an IDE can read and publish without any Nostr-specific code. + +```bash +java -jar nostr-java-mcp.jar keygen personal # create a key; the private half is never printed +java -jar nostr-java-mcp.jar -Dnostr.mcp.identity=personal ``` -- **nostr-java-core** — Foundation utilities, BIP-340 Schnorr cryptography, Bech32 encoding, hex conversion -- **nostr-java-event** — `GenericEvent`, `GenericTag`, `Kinds` constants, `EventFilter` builder, messages, JSON serialization -- **nostr-java-identity** — `Identity` key management, event signing, NIP-04/NIP-44 encryption -- **nostr-java-client** — `NostrRelayClient` WebSocket client with retry, Virtual Threads, and async APIs +Publishing to Nostr is public and cannot be reliably undone, so writes are confirmed by default: +the agent gets a preview and a token, and nothing is published until it calls again with that +token. A hallucinated post therefore becomes a no-op. See +[running the MCP server](docs/howto/run-the-mcp-server.md). -## Running Tests +## Modules -- Full test suite (requires Docker for Testcontainers ITs): +Six modules with a strict dependency chain, each usable on its own: - `mvn -q verify` +``` +nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client → nostr-java-api → nostr-java-mcp +``` -- Without Docker (skips Testcontainers-based integration tests via profile): +| Module | What it gives you | +| --- | --- | +| **core** | BIP-340 Schnorr signatures, Bech32 encoding, hex conversion | +| **event** | `GenericEvent`, `GenericTag`, `Kinds`, `EventFilter`, JSON serialisation | +| **identity** | `Identity` key management, signing, NIP-04 and NIP-44 encryption, NIP-59 gift wrapping | +| **client** | `NostrRelayClient` websocket transport with retry; `RelayPool` for fan-out and fan-in | +| **api** | `NostrClient`: multi-relay publishing with per-relay outcomes, de-duplicated subscriptions, NIP-17 delivery | +| **mcp** | An MCP server exposing the SDK to LLM agents over stdio or HTTP | + +Most applications want `nostr-java-api`. Reach further down only when you need something it +does not expose. + +## Design + +- **One event class.** `GenericEvent` covers every kind, and `GenericTag` holds a code plus its + parameters. Nostr's own model is integers and string arrays, so a type hierarchy on top would + be a second model to keep in step with the first. +- **NIP-agnostic.** Any current or future NIP works through + `GenericEvent.builder().kind(n)` with the right tags. Supporting a new NIP needs no library + release. `Kinds` names the common values without restricting the rest. +- **Multi-relay by default.** Nostr has no single source of truth, so publishing fans out and + subscribing fans in with de-duplication. +- **Virtual threads.** Relay I/O and listener dispatch run on Java 21 virtual threads; the + async surface is `CompletableFuture`. +- **Failures are reported, not swallowed.** Per-relay outcomes, typed + `RelayTimeoutException`, and connection state you can inspect. - `mvn -q -Pno-docker verify` +## Documentation -## Troubleshooting +Start at the [documentation index](docs/README.md), which is organised by what you are trying to +do. The most common destinations: -For diagnosing relay send issues and capturing failure details, see the how-to guide: [docs/howto/diagnostics.md](docs/howto/diagnostics.md). +- [Getting started](docs/GETTING_STARTED.md) — install and publish a first note +- [API examples](docs/howto/api-examples.md) — worked examples of the common tasks +- [Private direct messages](docs/howto/private-direct-messages.md) — NIP-17 gift wrapping +- [Run the MCP server](docs/howto/run-the-mcp-server.md) — LLM agent access +- [API reference](docs/reference/nostr-java-api.md) — classes and methods +- [Architecture](docs/explanation/architecture.md) — how the modules fit together +- [Troubleshooting](docs/TROUBLESHOOTING.md) — when something is not working -## Documentation +## Building and testing -- Docs index: [docs/README.md](docs/README.md) — quick entry point to all guides and references. -- Getting started: [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — install via Maven/Gradle and build from source. -- API how-to: [docs/howto/use-nostr-java-api.md](docs/howto/use-nostr-java-api.md) — create, sign, and publish events. -- Streaming subscriptions: [docs/howto/streaming-subscriptions.md](docs/howto/streaming-subscriptions.md) — open and manage long-lived, non-blocking subscriptions. -- Custom events: [docs/howto/custom-events.md](docs/howto/custom-events.md) — working with custom event kinds. -- API reference: [docs/reference/nostr-java-api.md](docs/reference/nostr-java-api.md) — classes, key methods, and short examples. -- Events and tags: [docs/explanation/extending-events.md](docs/explanation/extending-events.md) — in-depth guide to GenericEvent and GenericTag. -- Architecture: [docs/explanation/architecture.md](docs/explanation/architecture.md) — module design and data flow. -- Codebase overview: [docs/CODEBASE_OVERVIEW.md](docs/CODEBASE_OVERVIEW.md) — layout, testing, and contribution workflow. -- Operations: [docs/operations/README.md](docs/operations/README.md) — logging, metrics, configuration, diagnostics. - -## Features - -- **Minimal API surface** — one event class (`GenericEvent`), one tag class (`GenericTag`), ~40 total classes -- **Protocol-aligned** — kinds are integers, tags are string arrays, no library-imposed type hierarchy -- **Virtual Thread concurrency** — relay I/O and listener dispatch on Java 21 Virtual Threads -- **Async APIs** — `connectAsync()`, `sendAsync()`, `subscribeAsync()` via `CompletableFuture` -- **Reliable connectivity** — Spring Retry, typed `RelayTimeoutException`, connection state tracking -- **NIP-04/NIP-44 encryption** — legacy and modern message encryption -- **BIP-340 Schnorr signatures** — event signing and verification -- **Well-documented** — architecture guides, how-to guides, and API reference - -## v2.0.0 Highlights - -- Simplified from 9 modules (~180 classes) to 4 modules (~40 classes) -- `GenericEvent` is the sole event class for all kinds — no subclasses -- `GenericTag` stores tags as `code` + `List` — no `ElementAttribute`, no `TagRegistry` -- `Kinds` utility replaces the `Kind` enum — any integer is valid -- `EventFilter` builder replaces 14 thin filter wrapper classes -- `NostrRelayClient` with Virtual Thread dispatch and async APIs -- `RelayTimeoutException` replaces silent empty-list timeout returns -- `java.util.HexFormat` replaces hand-rolled hex encoding - -See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. - -## NIP Support - -The library is NIP-agnostic by design. Any current or future NIP can be implemented using `GenericEvent.builder().kind(kindNumber)` with appropriate tags via `GenericTag.of(code, params...)` — no library updates required. The `Kinds` utility class provides named constants for commonly used kind values. +```bash +mvn verify # full suite, including Testcontainers integration tests (needs Docker) +mvn -Pno-docker verify # unit tests and non-Docker integration tests only +``` -## Contributing +Integration tests run against a real relay in a container rather than a stand-in, because the +failures worth catching, such as frame ordering and relay-side validation, are precisely the +ones a fake reproduces incorrectly. -Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for: -- Coding standards and conventions -- Pull request guidelines -- Testing requirements +## Contributing -For architectural guidance, see [docs/explanation/architecture.md](docs/explanation/architecture.md). +See the [codebase overview](docs/CODEBASE_OVERVIEW.md) for the module layout, build commands, +and the commit and pull request conventions, and +[the architecture guide](docs/explanation/architecture.md) for how the pieces fit together. +Release notes are in [CHANGELOG.md](CHANGELOG.md), and +[the migration guide](docs/MIGRATION.md) covers moving between major versions. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT. See [LICENSE](LICENSE). diff --git a/docs/CODEBASE_OVERVIEW.md b/docs/CODEBASE_OVERVIEW.md index 2cefd3e3..c54b70ca 100644 --- a/docs/CODEBASE_OVERVIEW.md +++ b/docs/CODEBASE_OVERVIEW.md @@ -6,16 +6,17 @@ This document provides an overview of the project structure and instructions for ## Module layout -nostr-java 2.0 has 4 modules with a clear dependency chain: +nostr-java has 5 modules with a clear dependency chain: ``` -nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client +nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client → nostr-java-api ``` - **nostr-java-core** — Foundation utilities, BIP-340 Schnorr cryptography, Bech32 encoding, hex conversion (`java.util.HexFormat`), validators, and exception hierarchy. No dependencies on other project modules. - **nostr-java-event** — `GenericEvent` (sole event class), `GenericTag` (sole tag class with `List` params), `Kinds` constants, `EventFilter` builder, relay messages, JSON serialization, `PublicKey`/`PrivateKey`/`Signature` value objects, and `ISignable` contract. - **nostr-java-identity** — `Identity` key management, event signing, and NIP-04/NIP-44 message encryption (`MessageCipher04`, `MessageCipher44`). -- **nostr-java-client** — `NostrRelayClient` WebSocket client with Spring Retry, Virtual Thread dispatch, async APIs (`connectAsync`, `sendAsync`, `subscribeAsync`), and connection state tracking. +- **nostr-java-client** — `NostrRelayClient` WebSocket client with Spring Retry, Virtual Thread dispatch, async APIs (`connectAsync`, `sendAsync`, `subscribeAsync`), and connection state tracking; `RelayPool` for fan-out publishing and de-duplicated fan-in subscriptions across many relays. +- **nostr-java-api** — `NostrClient`, the entry point for applications: signing and publishing in one call, subscriptions across every relay, and NIP-17 direct message delivery. ## Building and testing @@ -65,10 +66,36 @@ For practical usage examples, see: Before submitting changes: -1. **Run verification**: `mvn -q verify` — ensure all tests pass -2. **Follow code style**: Use clear, descriptive names and remove unused imports -3. **Write tests**: Include unit tests and update relevant documentation -4. **Follow commit conventions**: Use conventional commits (see [CONTRIBUTING.md](../CONTRIBUTING.md)) -5. **Submit PRs to develop branch**: All pull requests should target the `develop` branch +1. **Run verification**: `mvn verify` from the repository root, or `mvn -Pno-docker verify` if + you cannot run the container-backed integration tests. +2. **Write tests**: new behaviour needs a test, and a bug fix needs one that fails without the + fix. +3. **Update the documentation**: the guides are checked against the source by + `DocumentationAccuracyTest`, so a renamed type or method fails the build until the docs + follow. +4. **Follow code style**: clear, intention-revealing names; no unused imports; Clean Code + principles as described in [AGENTS.md](../AGENTS.md). -For detailed contribution guidelines, see [CONTRIBUTING.md](../CONTRIBUTING.md). +### Commit and pull request conventions + +Commit messages and PR titles follow +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): +`type(scope): description`, using one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, +`test`, `build`, `ci`, `chore` or `revert`. + +``` +feat(api): report per-relay outcomes when publishing +fix(client): deliver relay frames in the order they arrive +docs(readme): explain partial publish failure +``` + +The commit type drives the version bump: `fix` is a patch, `feat` a minor, and a +`BREAKING CHANGE` footer a major. See +[cutting a release](howto/version-uplift-workflow.md). + +Open pull requests with the template at `.github/pull_request_template.md` and complete every +section, citing the files you changed and the output of your test run. Link the issue in the +body with `Closes #123` so it closes on merge. + +Pull requests target **`main`**, which is the repository's default branch. Detailed commit +message requirements are in [commit_instructions.md](../commit_instructions.md). diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md new file mode 100644 index 00000000..cb4125a0 --- /dev/null +++ b/docs/CONTEXT.md @@ -0,0 +1,62 @@ +# Project Context + +Shared vocabulary for `nostr-java`. Terms here mean exactly what this file says they mean, in +conversation, in ADRs, and in code. When a term is fuzzy or overloaded, sharpen it here first. + +## Modules + +The dependency chain is strict and acyclic: + +``` +core → event → identity → client → api +``` + +- **core** — Schnorr cryptography, Bech32, hex, validators. No Nostr domain types. +- **event** — `GenericEvent`, `GenericTag`, `Kinds`, `EventFilter`, messages, JSON codecs. +- **identity** — `Identity` key management, signing, NIP-04/NIP-44 encryption, NIP-17 policy. + **Transport-free by design**: it produces plans, it never sends. +- **client** — WebSocket transport. `NostrRelayClient` (one relay) and `RelayPool` (many). +- **api** — Client-facing capability layer. See [ADR-0001](decisions/0001-introduce-nostr-java-api-module.md). + +## Terms + +**Relay pool** — A mutable, live set of relay connections, owning per-relay health, +reconnection, and the fan-out/fan-in of operations across members. Lives in `client`, not +`api`, because it is a transport concern. Not to be confused with a *relay list*. + +**Relay list** — A user's published set of relays, as event kinds 10002 (general) and 10050 +(direct messages). Data, not connections. A relay list is one input used to decide what goes +into a relay pool. + +**Fan-out** — Sending one operation to every relay in the pool. Applies to publishing. + +**Fan-in** — Merging inbound events from every relay in the pool into one stream, de-duplicated +by event `id`. Applies to subscriptions. + +**Publish result** — The per-relay outcome record returned by a publish: for each relay, +accepted, rejected with the relay's reason string, or timed out. Partial failure is ordinary +data; *total* failure throws. See [ADR-0002](decisions/0002-multi-relay-failure-semantics.md). + +**Delivery plan** — A `List` produced by `Nip17DirectMessageService`: which +gift wrap goes to which recipient, over which relays. A plan is inert. Executing it is the +`api` module's job, which is what keeps `identity` transport-free. + +**Gift wrap** — The NIP-59 outer event that conceals a NIP-17 direct message. A **rumor** is the +unsigned inner event; a **seal** is the middle layer. + +**Synthetic EOSE** — One end-of-stored-events signal the pool emits after every participating +relay has sent its own `EOSE`, or the timeout expires. It marks the transition from stored +events to live events. Individual relays' `EOSE` frames are pool internals and are not exposed. + +**Capability vs convenience** — The test the `api` module applies to every proposed method. A +**capability** is behaviour no lower module has, such as fan-out or delivery execution. +A **convenience** merely reorders calls the caller could already make. Convenience alone does +not justify a method. See [ADR-0003](decisions/0003-api-v1-service-scope.md). + +**In-flight ceiling** — `NostrRelayClient` permits one request in flight per connection, so the +pool serializes operations per relay. A known throughput limit, not a bug to be worked around +by opening more sockets. See [ADR-0004](decisions/0004-pool-concurrency-and-subscription-lifecycle.md). + +## Decisions + +Architecture decision records live in [docs/decisions/](decisions/). diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 3c2e913d..61197480 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -1,24 +1,26 @@ -# Getting Started +# Getting started -Navigation: [Docs index](README.md) · [API how-to](howto/use-nostr-java-api.md) · [Streaming subscriptions](howto/streaming-subscriptions.md) · [API reference](reference/nostr-java-api.md) · [Codebase overview](CODEBASE_OVERVIEW.md) +This guide takes you from an empty project to a signed note published on a Nostr relay. -## Prerequisites -- Maven -- Java 21+ +You need **Java 21 or later** and Maven. No Nostr account or API key: an identity is just a +keypair, and you generate one below. -## Building from Source +Navigation: [Docs index](README.md) · [API examples](howto/api-examples.md) · +[API reference](reference/nostr-java-api.md) · [Troubleshooting](TROUBLESHOOTING.md) -```bash -git clone https://github.com/tcheeric/nostr-java.git -cd nostr-java -mvn clean install -``` +## 1. Add the dependency -## Using Maven +Artifacts are published to `https://maven.398ja.xyz/releases`, with snapshots at +`https://maven.398ja.xyz/snapshots`. -Artifacts are published to `https://maven.398ja.xyz/releases` (and snapshots to `https://maven.398ja.xyz/snapshots`). +Replace `X.Y.Z` below with the current release. The +[releases page](https://github.com/tcheeric/nostr-java/releases) and the badge at the top of the +[project README](../README.md) both show it. This guide deliberately does not hard-code a +version number, because a stale one here is worse than a placeholder: it looks copyable and +quietly gives you an old library. -Use the BOM to align versions and omit per-module versions: +Most applications want **`nostr-java-api`**. It is the client-facing entry point, and it pulls in +the transport, signing and event modules for you. ```xml @@ -33,43 +35,25 @@ Use the BOM to align versions and omit per-module versions: xyz.tcheeric nostr-java-bom - + X.Y.Z pom import - + - xyz.tcheeric - nostr-java-client + nostr-java-api ``` -Or pick only the modules you need: +Importing the BOM is what lets you omit per-module versions. Every module then moves together, +which matters because they are released as a set. -```xml - - - - xyz.tcheeric - nostr-java-identity - - - - - xyz.tcheeric - nostr-java-event - - -``` - -Check the releases page for the latest BOM and module versions: https://github.com/tcheeric/nostr-java/releases - -## Using Gradle +**Gradle:** ```gradle repositories { @@ -78,8 +62,103 @@ repositories { dependencies { implementation platform('xyz.tcheeric:nostr-java-bom:X.Y.Z') - implementation 'xyz.tcheeric:nostr-java-client' + implementation 'xyz.tcheeric:nostr-java-api' } ``` -Replace X.Y.Z with the latest version from the releases page. +## 2. Publish your first note + +```java +import nostr.api.NostrClient; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.id.Identity; + +public class FirstNote { + // NoRelayAcceptedException is checked, so you have to decide what to do when a note reaches + // nobody. Declaring it here keeps the example short; a real application would catch it. + public static void main(String[] args) throws NoRelayAcceptedException { + // A Nostr identity is a keypair. Generating one is free and instant; there is nobody to + // register with. Keep the private key if you want to post as this account again. + Identity identity = Identity.generateRandomIdentity(); + System.out.println("posting as " + identity.getPublicKey().toBech32String()); + + try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz", "wss://nos.lol") + .build()) { + + PublishResult result = nostr.publishTextNote("Hello from nostr-java"); + + System.out.println("stored by: " + result.getAcceptingRelays()); + result.getFailures().forEach(failure -> + System.out.println("refused by " + failure.relayUri() + ": " + failure.findReason().orElse("no reason given"))); + } + } +} +``` + +Two things worth noticing, because they shape everything else in this library. + +**Publishing is not all-or-nothing.** Nostr has no single source of truth, so a note goes to +several relays and each answers for itself. `PublishResult` reports every relay separately. +`publishTextNote` throws only when *no* relay accepted the event; if even one stored it, the note +is published and you should not send it again. + +**The client owns connections.** It is `AutoCloseable`, so the try-with-resources block above +closes the relay sockets on exit. Outside a short example, build one client and keep it. + +## 3. Read it back + +```java +try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz") + .build()) { + + nostr.subscribe( + List.of(EventFilter.builder().author(identity.getPublicKey().toHexString()).kind(1).build()), + event -> System.out.println(event.getContent())); +} +``` + +Subscriptions de-duplicate across relays, so an event carried by three relays reaches your +listener once. + +## Which module do I need? + +`nostr-java-api` is the right answer unless you have a reason to go lower. Each module below it +is usable alone, and each drops what the one above it adds: + +| Module | Use it when | +| --- | --- | +| `nostr-java-api` | You want to publish and subscribe. **Start here.** | +| `nostr-java-client` | You need direct control of relay connections and the pool | +| `nostr-java-identity` | You only need keys, signing and encryption, with no networking | +| `nostr-java-event` | You only need the event model and JSON, with no signing | +| `nostr-java-core` | You only need the cryptographic and encoding primitives | +| `nostr-java-mcp` | You are giving an LLM agent access, not writing Java against the API | + +## Building from source + +```bash +git clone https://github.com/tcheeric/nostr-java.git +cd nostr-java +mvn clean install +``` + +Running the full test suite needs Docker, because the integration tests publish to a real relay +in a container: + +```bash +mvn verify # everything +mvn -Pno-docker verify # skips the container-backed tests +``` + +## Where next + +- [API examples](howto/api-examples.md) — worked examples of the common tasks +- [Publishing across many relays](howto/multi-relay-publishing.md) — partial failure in depth +- [Private direct messages](howto/private-direct-messages.md) — NIP-17 encrypted messaging +- [Run the MCP server](howto/run-the-mcp-server.md) — give an LLM agent access +- [Troubleshooting](TROUBLESHOOTING.md) — when a relay will not accept your event diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index f97bb0f3..d81ecfc2 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -45,7 +45,7 @@ Version 0.5.1 introduces a major dependency management change: **nostr-java now xyz.tcheeric nostr-java-bom - + X.Y.Z pom import @@ -62,7 +62,7 @@ Version 0.5.1 introduces a major dependency management change: **nostr-java now xyz.tcheeric nostr-java-bom - + X.Y.Z pom import @@ -91,7 +91,7 @@ Version 0.5.1 introduces a major dependency management change: **nostr-java now xyz.tcheeric nostr-java-bom - + X.Y.Z pom import @@ -256,7 +256,7 @@ After migration, verify your setup: xyz.tcheeric nostr-java-bom - + X.Y.Z pom import @@ -392,7 +392,7 @@ If you need assistance with migration: 1. **Check the docs**: [docs/README.md](README.md) 2. **Search issues**: [GitHub Issues](https://github.com/tcheeric/nostr-java/issues) 3. **Ask for help**: Open a new issue with the `question` label -4. **Review examples**: Check the [`nostr-java-examples`](../nostr-java-examples) module for updated code patterns +4. **Review examples**: Work through the [how-to guides](howto/) for current code patterns --- diff --git a/docs/README.md b/docs/README.md index f0d415ba..ef45fb9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,47 +1,136 @@ -# Documentation Index +# nostr-java documentation -Quick links to the most relevant guides and references. +A Java implementation of the [Nostr protocol](https://github.com/nostr-protocol/nips): events, +signing, relays, encrypted messaging, and an MCP server that puts all of it in reach of an LLM +agent. -## Getting Started +## Start here -- [GETTING_STARTED.md](GETTING_STARTED.md) — Installation and setup via Maven/Gradle -- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — Common issues and solutions +| If you want to | Read | +| --- | --- | +| Add the library to a build and send your first note | [Getting started](GETTING_STARTED.md) | +| See worked examples of the common tasks | [API examples](howto/api-examples.md) | +| Understand how the modules fit together | [Architecture](explanation/architecture.md) | +| Look up a class or method | [API reference](reference/nostr-java-api.md) | +| Work out why something is failing | [Troubleshooting](TROUBLESHOOTING.md) | -## How-to Guides +The pages below are grouped by what they are for, following +[Diátaxis](https://diataxis.fr/): tutorials teach, how-to guides solve a problem, reference +describes the machinery, and explanation gives the reasoning. -- [howto/use-nostr-java-api.md](howto/use-nostr-java-api.md) — Quick start: create, sign, and send events -- [howto/api-examples.md](howto/api-examples.md) — Comprehensive examples for common use cases -- [howto/streaming-subscriptions.md](howto/streaming-subscriptions.md) — Long-lived subscriptions with NostrRelayClient -- [howto/custom-events.md](howto/custom-events.md) — Working with custom event kinds -- [howto/diagnostics.md](howto/diagnostics.md) — Inspecting relay failures and troubleshooting -- [howto/version-uplift-workflow.md](howto/version-uplift-workflow.md) — Tagging, publishing, and BOM alignment for releases -- [howto/configure-release-secrets.md](howto/configure-release-secrets.md) — Configure Maven Central and GPG secrets for releases -- [howto/ci-it-stability.md](howto/ci-it-stability.md) — Keep CI green and stabilize Docker-based ITs +## Tutorials -## Operations +Learning-oriented, for a first encounter with the library. -- [operations/README.md](operations/README.md) — Ops index (logging, metrics, config) +- [Getting started](GETTING_STARTED.md) — install via Maven or Gradle, generate an identity, + publish a note. -## Reference +## How-to guides -- [reference/nostr-java-api.md](reference/nostr-java-api.md) — API classes, methods, and examples +Task-oriented, for someone who knows what they want to achieve. -## Explanation +**Using the library** -- [explanation/extending-events.md](explanation/extending-events.md) — Working with events and tags (GenericEvent, GenericTag, Kinds) -- [explanation/architecture.md](explanation/architecture.md) — Module architecture and data flow -- [explanation/nostr-java-mcp-spec.md](explanation/nostr-java-mcp-spec.md) — Draft spec for the `nostr-java-mcp` MCP server module -- [explanation/nip-17-direct-messages-spec.md](explanation/nip-17-direct-messages-spec.md) — Draft spec for NIP-17 private direct messages and NIP-59 gift wrapping -- [explanation/dependency-alignment.md](explanation/dependency-alignment.md) — How versions are aligned via BOM +- [Create, sign and send events](howto/use-nostr-java-api.md) — the shortest path from a + keypair to a published event. +- [API examples](howto/api-examples.md) — worked examples of the common tasks, in one place. +- [Publish and subscribe across many relays](howto/multi-relay-publishing.md) — `NostrClient`, + partial failure, and reading results back. +- [Send private direct messages](howto/private-direct-messages.md) — NIP-17 gift wrapping and + delivery to each recipient's own relays. +- [Stream long-lived subscriptions](howto/streaming-subscriptions.md) — staying connected and + handling events as they arrive. +- [Work with custom event kinds](howto/custom-events.md) — events and tags the library does not + model directly. +- [Run the MCP server](howto/run-the-mcp-server.md) — let an LLM agent use Nostr, with the + safety model explained. -## Developer +**Operating and diagnosing** -- [developer/SIMPLIFICATION_PROPOSAL.md](developer/SIMPLIFICATION_PROPOSAL.md) — 2.0 design simplification proposal +- [Diagnose relay failures](howto/diagnostics.md) — finding out which relay refused what, and + why. +- [Configure the library](operations/configuration.md) — timeouts, retries, and connection + settings. +- [Configure logging](operations/logging.md) — what is logged, at which level, and how to + change it. +- [Collect metrics](operations/metrics.md) — the Micrometer metrics exposed and what they mean. -## Project +**Releasing and contributing** -- [CODEBASE_OVERVIEW.md](CODEBASE_OVERVIEW.md) — Codebase layout, testing, contributing +- [Cut a release](howto/version-uplift-workflow.md) — tagging, publishing, and BOM alignment. +- [Configure release secrets](howto/configure-release-secrets.md) — Maven Central and GPG + credentials. +- [Keep CI green](howto/ci-it-stability.md) — stabilising the Docker-backed integration tests. +- [Maintain the roadmap project](howto/manage-roadmap-project.md) — the GitHub project board. + +## Reference -## Tests Overview +Information-oriented, for looking things up. + +- [API reference](reference/nostr-java-api.md) — classes, methods and signatures across the + modules. +- [Migration guide](MIGRATION.md) — what changed between major versions and how to move. +- [Troubleshooting](TROUBLESHOOTING.md) — symptoms, causes and fixes. +- [Operations index](operations/README.md) — configuration, logging and metrics at a glance. +- [Codebase overview](CODEBASE_OVERVIEW.md) — module layout, build and test commands. +- [Shared vocabulary](CONTEXT.md) — what this project means by pool, publish result, and + delivery plan. + +## Explanation -- Client module (Spring WebSocket): `nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md` — send/subscribe retries and timeout behavior +Understanding-oriented, for the reasoning behind the design. + +- [Architecture](explanation/architecture.md) — the modules, their dependencies, and how data + flows between them. +- [Working with events and tags](explanation/extending-events.md) — `GenericEvent`, + `GenericTag` and the kind ranges. +- [Dependency alignment](explanation/dependency-alignment.md) — why versions are managed + through a BOM. +- [Secure coding guidelines](developer/SECURE_CODING.md) — the rules this codebase follows + around keys and encryption. + +**Specifications** + +- [MCP server specification](explanation/nostr-java-mcp-spec.md) — the design of + `nostr-java-mcp`, including its safety model. +- [NIP-17 direct messages](explanation/nip-17-direct-messages-spec.md) — private messages and + NIP-59 gift wrapping. + +**Proposals and history** + +These describe work that is proposed, in progress, or finished. They are kept because the +reasoning outlives the change. + +- [Simplification proposal](developer/SIMPLIFICATION_PROPOSAL.md) — a proposed reduction of the + 2.0 design. Describes code that does not all exist yet. +- [1.0 roadmap](explanation/roadmap-1.0.md) — historical, kept for context. +- [Generic tag fragility](problems/GENERIC_TAG_GETCODE_FRAGILITY.md) — analysis of a + `GenericTag.getCode()` failure and its downstream effects. +- [Integration test bug analysis](integration-test-bug-analysis.md) — why the relay container + sometimes starts inert, and how the tests handle it. + +## Decisions + +Architecture decision records: what was decided, and what was given up. + +- [0001 — Introduce `nostr-java-api`](decisions/0001-introduce-nostr-java-api-module.md) — why + the module exists and what it deliberately is not. +- [0002 — Multi-relay failure semantics](decisions/0002-multi-relay-failure-semantics.md) — + partial failure, pool construction, de-duplication. +- [0003 — API v1 service scope](decisions/0003-api-v1-service-scope.md) — which services ship, + and where NIP-17 orchestration lives. +- [0004 — Pool concurrency and subscription lifecycle](decisions/0004-pool-concurrency-and-subscription-lifecycle.md) + — per-relay serialisation, parsed payloads, auto-resubscribe. +- [0005 — Pool membership, EOSE and ownership](decisions/0005-pool-membership-eose-and-ownership.md) + — mutable membership, synthetic EOSE, resource ownership. + +## How this documentation is kept honest + +`DocumentationAccuracyTest` in `nostr-java-api` runs with the ordinary build and fails when the +documentation drifts from the code: a guide naming a type or method that does not exist, a link +pointing at nothing, an install snippet quoting a version that is not the one being built, or a +page nobody links to from this index. + +It found real problems when it was written, including a reference page teaching +`BaseMessage.read(json)`, a method that has never existed. Documentation rots quietly because +nothing fails when it does, which is exactly why it is worth a test. diff --git a/docs/decisions/0001-introduce-nostr-java-api-module.md b/docs/decisions/0001-introduce-nostr-java-api-module.md new file mode 100644 index 00000000..fa1dda14 --- /dev/null +++ b/docs/decisions/0001-introduce-nostr-java-api-module.md @@ -0,0 +1,49 @@ +# ADR-0001: Introduce a `nostr-java-api` module + +- **Status**: Accepted +- **Date**: 2026-08-30 + +## Context + +The SDK exposes four modules in a strict chain: `core → event → identity → client`. +Clients assemble events by hand, sign them with an `Identity`, and publish them through a +`NostrRelayClient` bound to a **single** relay URI. + +Three capability gaps follow from that shape: + +- **No relay pool.** Nostr is inherently multi-relay: publish to N relays, subscribe across + N relays with de-duplication. No module owns this. +- **No delivery execution.** `Nip17DirectMessageService.planDelivery` returns a + `List` that nothing executes. The seam dangles. +- **No relay-list lookup.** `DirectMessageRelayLookup` has no implementation, because a real + one must fetch kind 10050 events, which requires a relay pool. + +## Decision + +Add `nostr-java-api` as a **capability layer**, not a wrapper. It exposes a `NostrClient` +facade over services that add behaviour the lower modules deliberately do not have. + +1. **Purpose**: capability layer first; convenience is a consequence, not the goal. + `nostr-java-api` is *not* a sealed public API boundary: callers may still import + `GenericEvent`, `GenericTag` and `EventFilter` directly, preserving the protocol-aligned, + no-imposed-hierarchy design. +2. **Relay pool lives in `nostr-java-client`.** A pool is a transport concern, usable without + buying into the facade. This keeps `api` thin and avoids a fifth module for one class. +3. **`NostrClient` owns an `Identity`**, supplied at build time so build → sign → publish + collapses into one call. A per-call identity override supports signing bots and + multi-account hosts. +4. **Plain-Java construction.** `NostrClient.builder()` must work in a `main()` method with no + Spring application context, even though `nostr-java-client` depends on Spring internally. + A `nostr-java-spring-boot-starter` is a later, separate release decision. +5. **Sync-first API.** Fan-out across relays blocks cheaply on Virtual Threads, and the sync + signature reads better. Async variants are added only where fan-out blocking is material. + Subscriptions remain callback-based and are unaffected. + +## Consequences + +- The module graph becomes `core → event → identity → client → api`; nothing depends on `api`. +- `nostr-java-client` grows a `RelayPool` alongside `NostrRelayClient`. +- `DirectMessageRelayLookup` gains a relay-backed implementation in `api`. +- The facade returns core types (`GenericEvent`), so no parallel event model is introduced. +- Rejecting the sealed-boundary option means `api` must justify itself by capability; any + method that only reorders existing calls does not belong in it. diff --git a/docs/decisions/0002-multi-relay-failure-semantics.md b/docs/decisions/0002-multi-relay-failure-semantics.md new file mode 100644 index 00000000..bf5e148e --- /dev/null +++ b/docs/decisions/0002-multi-relay-failure-semantics.md @@ -0,0 +1,56 @@ +# ADR-0002: Multi-relay failure semantics + +- **Status**: Accepted +- **Date**: 2026-08-30 +- **Extends**: [ADR-0001](0001-introduce-nostr-java-api-module.md) + +## Context + +Fanning one operation out to many relays makes **partial failure the normal case**. Publishing +to five relays can plausibly yield three acceptances, one protocol-level rejection +(`blocked: pubkey banned`), and one timeout, all in a single call. A single-relay client has no +vocabulary for this: `NostrRelayClient.send` either returns or throws. + +Two prior lessons constrain the answer. v2.0.0 replaced silent empty-list timeout returns with +a typed `RelayTimeoutException`, so total failure must not be reported as an ordinary value. +And a long-lived firehose subscription receives the same event from every connected relay, so +de-duplication cannot be optional. + +## Decision + +### Publishing + +`publish` returns a **`PublishResult`** recording each relay's outcome, and **throws when zero +relays accepted the event**. + +Partial failure is data the caller inspects; total failure is exceptional. Returning `void` and +throwing only on total failure would hide which relays rejected and why. Never throwing would +reintroduce the silent-failure bug v2.0.0 removed. + +### Pool construction + +`RelayPool` is **best-effort**: it connects the relays it can, marks unreachable ones as down, +and retries them lazily. Construction does not fail because a member relay is unreachable. + +Relays are unreliable by nature, and all-or-nothing construction makes the pool as brittle as +its worst member. The cost is that connection health becomes the pool's responsibility: +per-relay `ConnectionState` must be observable, and reconnection policy is owned by the pool. + +### Subscription de-duplication + +Fan-in subscriptions de-duplicate by event `id` through a **bounded LRU window**, sized by +configuration with a default in the low thousands. + +An unbounded set leaks memory on exactly the long-lived subscriptions that need it most. +Pushing de-duplication to callers would offload the one behaviour every multi-relay consumer +needs. + +## Consequences + +- `PublishResult` is a new public value type: per-relay accepted/rejected/timed-out plus the + relay's reason string. +- The pool exposes per-relay health; callers can distinguish "rejected" from "never asked". +- Events arriving after eviction from the LRU window can be re-delivered. The window default + must exceed any realistic cross-relay arrival spread. +- Callers who want strict all-relay delivery must check `PublishResult` themselves; the API + does not offer an all-or-nothing publish mode. diff --git a/docs/decisions/0003-api-v1-service-scope.md b/docs/decisions/0003-api-v1-service-scope.md new file mode 100644 index 00000000..cb432fcc --- /dev/null +++ b/docs/decisions/0003-api-v1-service-scope.md @@ -0,0 +1,45 @@ +# ADR-0003: `nostr-java-api` v1 service scope + +- **Status**: Accepted +- **Date**: 2026-08-30 +- **Extends**: [ADR-0001](0001-introduce-nostr-java-api-module.md) + +## Context + +A facade attracts convenience methods. ADR-0001 committed the module to being a **capability +layer**, so each service must add behaviour that no existing module has, rather than reordering +calls the caller could already make. + +## Decision + +v1 ships four services behind `NostrClient`: + +| Service | Justification | +| --- | --- | +| `publish` | Fan-out across the pool and per-relay result aggregation exist nowhere today. | +| `subscriptions` | Fan-in across relays with de-duplication exists nowhere today. | +| `directMessages` | Executes the NIP-17 delivery plan that `identity` can only produce. | +| relay-list lookup | Required to implement `DirectMessageRelayLookup` (kind 10050). | + +**Deferred**: general profile handling (kind 0 metadata, kind 3 contacts, kind 10002 relay +lists beyond the DM case). These are convenience over capability, and are cheap to add once +the pool has proven itself. + +### NIP-17 orchestration lives in `api` + +`Nip17DirectMessageService.planDelivery` returns a `List` precisely so that +`nostr-java-identity` stays transport-free. The API module calls `planDelivery`, then feeds the +resulting plan to the relay pool. + +Pushing an executor down into `identity` behind an inverted transport interface would give that +module a transport concern it was deliberately designed to avoid. Orchestrating across a +policy module and a transport module is what a capability layer is for. + +## Consequences + +- `identity` gains no new dependencies; the `MessageDelivery` seam is finally consumed. +- The relay-list lookup is implemented in `api` and injected into `identity`'s + `DirectMessageRelayLookup` interface, keeping the dependency arrow pointing the right way. +- `NostrClient` grows a `profiles()` accessor only when the deferred work lands, so its absence + in v1 is not a breaking gap. +- Any proposed method that merely reorders existing calls is rejected by this ADR's test. diff --git a/docs/decisions/0004-pool-concurrency-and-subscription-lifecycle.md b/docs/decisions/0004-pool-concurrency-and-subscription-lifecycle.md new file mode 100644 index 00000000..50543849 --- /dev/null +++ b/docs/decisions/0004-pool-concurrency-and-subscription-lifecycle.md @@ -0,0 +1,68 @@ +# ADR-0004: Relay pool concurrency, payload types, and subscription lifecycle + +- **Status**: Accepted +- **Date**: 2026-08-30 +- **Extends**: [ADR-0002](0002-multi-relay-failure-semantics.md) + +## Context + +Two properties of `NostrRelayClient` constrain any pool built on it. + +**One request in flight per connection.** `send` throws +`IllegalStateException: A request is already in flight` when a second call overlaps the first. +A pool cannot simply fan out concurrently across shared connections. + +**Raw JSON at the boundary.** `send` returns `List` and `subscribe` takes a +`Consumer`. Nothing above the wire format is parsed for the caller. + +## Decision + +### Concurrency: serialize per relay + +The pool **serializes operations per relay**. Concurrent publishes to the same relay queue +behind a lock. Fan-out remains concurrent *across* relays. + +Opening several connections per relay URI would multiply sockets and relays penalise that. +The correct fix is to make `NostrRelayClient` multiplex, routing responses by subscription and +event id, but that is a rewrite of its request/response core and would swallow this effort. + +**This is a known ceiling**: throughput to any single relay is bounded by round-trip latency. +Multiplexing is the follow-up, tracked separately. + +### Payload types: the API deals in parsed events + +Subscription callbacks receive **`GenericEvent`**, not raw JSON, and `PublishResult` carries +parsed `OK` outcomes. + +This is capability rather than convenience. De-duplication by event `id` (ADR-0002) forces the +pool to parse inbound payloads regardless, and per-relay publish outcomes require parsing `OK` +messages for the accepted flag and reason string. Once parsed, handing back the string would be +perverse. + +### Subscription lifecycle: auto-resubscribe and notify + +When a relay drops mid-stream the pool **notifies an error callback and re-subscribes when that +relay reconnects**, replaying the stored filter. + +A long-lived subscription that silently degrades from five relays to one, with the caller never +informed, is the characteristic multi-relay failure. Auto-resubscription is the main reason the +pool owns reconnection policy at all. + +### Publishing waits for all relays, bounded by a timeout + +`publish` waits for every relay's `OK` up to a pool-level timeout. Relays that do not answer in +time are recorded as **timed out** in the `PublishResult`. + +Returning on first acceptance would leave most `PublishResult` entries unresolved. A +configurable quorum is a knob that will not be tuned correctly. A timeout is the honest bound +and makes slowness a first-class, visible outcome instead of a hidden race. + +## Consequences + +- The pool stores each subscription's filter for replay, so subscriptions are stateful objects + rather than fire-and-forget handles. +- Publish latency is the slowest responding relay, capped by the timeout. Callers needing lower + latency must publish asynchronously. +- Per-relay serialization means a slow relay delays only its own queue, not the fan-out. +- Parsing at the pool boundary means malformed relay payloads must be handled there, and + reported without killing the subscription. diff --git a/docs/decisions/0005-pool-membership-eose-and-ownership.md b/docs/decisions/0005-pool-membership-eose-and-ownership.md new file mode 100644 index 00000000..7dc4d2fd --- /dev/null +++ b/docs/decisions/0005-pool-membership-eose-and-ownership.md @@ -0,0 +1,55 @@ +# ADR-0005: Pool membership, EOSE aggregation, and resource ownership + +- **Status**: Accepted +- **Date**: 2026-08-30 +- **Extends**: [ADR-0004](0004-pool-concurrency-and-subscription-lifecycle.md) + +## Context + +Three lifecycle questions remained after the pool's failure semantics were settled. + +NIP-17 delivery is addressed to **the recipient's** relays, discovered from their kind 10050 +relay list. Those are not the relays the caller configured, so a delivery plan routinely names +relays the pool has never heard of. + +A REQ returns stored events, then `EOSE`, then live events. Fanned across five relays there are +five separate `EOSE` frames, and applications still need one answer to "is the backlog drained". + +## Decision + +### Pool membership is mutable + +`RelayPool` supports adding and removing relays at runtime. NIP-17 delivery adds the +recipient's relays on demand; connections are released by reference counting or idle eviction. + +An immutable pool would force DM delivery to open ad-hoc connections outside it, creating a +second code path for connection handling, health tracking, and shutdown. Separate persistent +and transient pools would double the lifecycle for the same reason. One mutable pool covers +both cases with one mechanism. + +### Subscriptions emit a single synthetic EOSE + +The pool aggregates per-relay `EOSE` frames and signals **one** end-of-stored-events to the +caller once every participating relay has reported, bounded by the same kind of timeout that +bounds publishing. + +"The backlog is drained, we are live now" drives spinners and initial-render decisions, so +hiding it entirely is not viable, and exposing per-relay `EOSE` leaks pool internals. The +timeout matters: an unresponsive relay must not stall the synthetic `EOSE` indefinitely. + +### Resource ownership follows construction + +`NostrClient` closes the pool it built from relay URIs. A pool passed in by the caller is not +closed by `NostrClient`. + +This is the conventional Java ownership rule, and it lets the facade be used in a container +where the pool outlives it. + +## Consequences + +- The pool tracks per-relay usage so transiently added DM relays are eventually evicted. +- A relay added for DM delivery is available to other operations while it remains connected, + which is a benign but real sharing of state. +- Subscriptions have three phases (stored, synthetic EOSE, live) that the API must express. +- Callers who pass their own pool are responsible for closing it, which must be documented + prominently on the builder. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index e4cf722e..782af016 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -28,17 +28,17 @@ nostr-java 2.0 follows a **minimalist, protocol-aligned** design: - **One event class** — `GenericEvent` handles every Nostr event kind via `int kind`. No subclasses. - **One tag class** — `GenericTag` stores a code and `List` params. No subclasses, no `ElementAttribute`. - **Integer kinds** — `Kinds` utility provides named constants (`Kinds.TEXT_NOTE`, `Kinds.CONTACT_LIST`) and range checks. Any integer is valid — no enum gating. -- **4 modules** — each with a clear, focused purpose and a strict dependency chain. +- **5 modules** — each with a clear, focused purpose and a strict dependency chain. - **Virtual Threads** — relay I/O and listener dispatch use Java 21 Virtual Threads for lightweight concurrency. -This design reduced the library from ~180 classes across 9 modules to ~40 classes across 4 modules, eliminating all NIP-specific concrete types, entity DTOs, factory hierarchies, and annotation-driven tag registration. +This design reduced the library from ~180 classes across 9 modules to ~40 classes across 4 modules (later joined by `nostr-java-api`), eliminating all NIP-specific concrete types, entity DTOs, factory hierarchies, and annotation-driven tag registration. --- ## Modules ``` -nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client +nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client → nostr-java-api ``` ### `nostr-java-core` @@ -88,11 +88,32 @@ nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-clie - `RelayTimeoutException` — Typed exception for relay timeouts (replaces silent empty-list returns). - `ConnectionState` — Enum: `CONNECTING`, `CONNECTED`, `RECONNECTING`, `CLOSED`. - `NostrRetryable` / `RetryConfig` — Spring Retry annotation and configuration. +- `RelayConnection` / `RelayConnectionFactory` — The seam between relay coordination and transport, exposing only what a pool needs. Lets code above it be tested against scripted relays and depend on no Spring type. +- `RelayPool` — A mutable set of relay connections. Publishes to all of them at once, collecting per-relay outcomes, and merges their subscriptions into one de-duplicated stream. Serialises per relay, because a connection serves one request at a time. +- `PublishResult` / `RelayPublishOutcome` / `NoRelayAcceptedException` — Per-relay publish results; partial failure is data, total failure throws. +- `RelaySubscription` / `SubscriptionListener` — A subscription across many relays, retaining its filter so a relay that drops can be re-subscribed on recovery. **Dependencies:** `nostr-java-identity`, Spring WebSocket, Spring Retry. --- +### `nostr-java-api` +**Purpose:** The client-facing entry point, owning the orchestration every application would +otherwise repeat. + +**Key classes:** +- `NostrClient` — An identity plus a relay set. Signs and publishes in one call, subscribes across every relay, and sends NIP-17 direct messages. Returns core types, so callers can still drop down to `RelayPool` or build events by hand. +- `RelayListLookup` — Implements `nostr-java-identity`'s `DirectMessageRelayLookup` by querying relays for kind-10050 lists. That module declares the need but cannot meet it without acquiring a transport. +- `DirectMessagePublisher` — Performs the delivery plan `nostr-java-identity` can only produce, sending each gift wrap to its own recipient's relays. +- `RecipientDeliveryOutcome` — Whether one participant received a message, since a group message can partly succeed. + +**Dependencies:** `nostr-java-client`. + +Nothing depends on this module. It adds capability rather than sealing the layers below it, so +an application may keep using `GenericEvent`, `EventFilter` and `RelayPool` directly. + +--- + ## Data Flow ``` @@ -276,7 +297,7 @@ NostrRuntimeException (base) nostr-java 2.0 provides: -- **Minimal API surface** — one event class, one tag class, ~40 total classes across 4 modules +- **Minimal API surface** — one event class, one tag class, and a small client-facing module on top - **Protocol-aligned design** — kinds are integers, tags are string arrays, no library-imposed type hierarchy - **Virtual Thread concurrency** — relay I/O and listener dispatch on lightweight threads - **Reliable connectivity** — typed timeout exceptions, connection state tracking, Spring Retry diff --git a/docs/explanation/dependency-alignment.md b/docs/explanation/dependency-alignment.md index f7a3f3be..65a2e3f2 100644 --- a/docs/explanation/dependency-alignment.md +++ b/docs/explanation/dependency-alignment.md @@ -2,18 +2,18 @@ This document explains how nostr-java aligns dependency versions across modules and how the BOM manages consumer dependencies. -## Current state (2.0.0) +## Current state (2.2.0) - The aggregator POM imports `nostr-java-bom` to manage third-party versions. -- Temporary overrides pin each reactor module (`nostr-java-core`, `nostr-java-event`, `nostr-java-identity`, `nostr-java-client`) to `${project.version}` so local builds resolve to the in-repo SNAPSHOTs even if the BOM doesn't yet list matching coordinates. +- Temporary overrides pin each reactor module (`nostr-java-core`, `nostr-java-event`, `nostr-java-identity`, `nostr-java-client`, `nostr-java-api`) to `${project.version}` so local builds resolve to the in-repo SNAPSHOTs even if the BOM doesn't yet list matching coordinates. - Relevant configuration lives in `pom.xml` dependencyManagement. ## Module structure -4 modules with a strict dependency chain: +5 modules with a strict dependency chain: ``` -nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client +nostr-java-core → nostr-java-event → nostr-java-identity → nostr-java-client → nostr-java-api ``` ## BOM alignment @@ -52,7 +52,7 @@ Consumers should import the BOM and omit versions on nostr-java dependencies: Ensure the build resolves to correct coordinates via the BOM: ```bash -mvn -q -DnoDocker=true clean verify +mvn -q -Pno-docker clean verify mvn -q dependency:tree | rg "nostr-java-(core|event|identity|client)" ``` diff --git a/docs/explanation/extending-events.md b/docs/explanation/extending-events.md index 91360309..0ceff15d 100644 --- a/docs/explanation/extending-events.md +++ b/docs/explanation/extending-events.md @@ -269,7 +269,7 @@ void testSerialization() throws Exception { GenericEvent event = createAndSignEvent(); String json = new EventMessage(event).encode(); - BaseMessage decoded = BaseMessage.read(json); + BaseMessage decoded = new BaseMessageDecoder<>().decode(json); assertTrue(decoded instanceof EventMessage); GenericEvent deserialized = ((EventMessage) decoded).getEvent(); diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index 6d954a06..c6d6400e 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -6,11 +6,15 @@ can read from and write to Nostr relays using natural language. It is a design explanation: it states the goals, the boundaries, the tool surface, and the open decisions. It is not yet an implementation guide. +**Baseline: nostr-java 2.2.0.** The module builds on `nostr-java-api` and its `NostrClient` +entry point. An earlier draft targeted 2.0.x and planned to build relay pooling, subscription +merging, and NIP-17 itself; the SDK now provides all three, so this revision consumes them +instead. §5 lists what that removes. + ## 1. Motivation -Today an application must speak Java to use nostr-java: build an event, sign it with an -`Identity`, and publish it through `NostrRelayClient`. An LLM agent cannot do that -directly. MCP is the emerging standard for giving an agent typed, discoverable +Today an application must speak Java to use nostr-java: name an identity and some relays, +then drive `NostrClient` from `nostr-java-api`. An LLM agent cannot do that directly. MCP is the emerging standard for giving an agent typed, discoverable capabilities over stdio or HTTP. Wrapping the SDK in an MCP server means any MCP client (Claude Desktop, Claude Code, IDE agents, custom hosts) gets Nostr access with no Nostr-specific code, and the SDK gains a natural-language front door without polluting @@ -28,7 +32,7 @@ the existing modules. - Offer a **process-per-identity** deployment for users who want hard isolation between accounts, without forcing it on users who want one server across all of theirs. - Support both stdio (local desktop agents) and streamable HTTP (remote/hosted) transports. -- Be strictly additive: no changes required in `core`, `event`, `identity`, or `client`. +- Be strictly additive: no changes required in `core`, `event`, `identity`, `client`, or `api`. - Make every destructive or public-facing action (anything that writes to a relay) opt-in and auditable. @@ -37,34 +41,43 @@ the existing modules. - No relay implementation. The module is a client only. - No LLM inference. Natural-language understanding lives in the MCP host, not here. - No persistent event store. Live subscriptions buffer in memory only (§5.1). -- No new NIP support beyond NIP-17, which this module needs and the SDK lacks (§5.2). - Everything else missing is implemented in `nostr-java-event`, not here. +- No NIP implementation in this module. NIP-17, the one gap that used to block it, shipped in + 2.1.0. One gap remains: kind-3 contact lists have no SDK type (§12), and that belongs in + `nostr-java-event` where every consumer benefits, not here. - No remote signing (NIP-46) in v1. Keys are held locally; see §6. ## 3. Resolved decisions | Decision | Choice | | --- | --- | +| Baseline | Built on nostr-java **2.2.0**, depending on `nostr-java-api`. | | MCP library | Official SDK, `io.modelcontextprotocol.sdk:mcp` (2.x), plus its stdio and HTTP transports. Not Spring AI's starter. | -| Signing | Local `nsec`/hex keys in an `IdentityVault`, unlocked at startup. NIP-46 deferred but designed for. | -| Subscriptions | Long-lived streaming, surfaced as MCP resources with change notifications. | +| Signing | Local `nsec`/hex keys in an `IdentityVault`, unlocked at startup. Default backend is the OS keychain (§12). NIP-46 deferred but designed for. | +| Subscriptions | Long-lived streaming on `RelayPool.subscribe`, surfaced as MCP resources with change notifications. | | Packaging | Executable jar plus `Dockerfile` and `docker-compose.yml`. | -| Direct messages | NIP-17 gift-wrapped DMs. Requires NIP-17 support to be added to `nostr-java-event` first. | +| Direct messages | NIP-17 gift-wrapped DMs, delegated to `nostr-java-api`'s `DirectMessagePublisher`. No SDK work required. | +| SDK prerequisites | One: a `ContactList` type over kind-3 in `nostr-java-event`, blocking the social phase only (§12). | | Identity isolation | Optional **single-identity mode** binding one server process to one identity, deployed as one process per identity (§6.3.1). Not one thread per identity. | ## 4. Position in the module graph ``` -nostr-java-core ──▶ nostr-java-event ──▶ nostr-java-identity ──▶ nostr-java-client - │ - ▼ - nostr-java-mcp +nostr-java-core ──▶ nostr-java-event ──▶ nostr-java-identity ──▶ nostr-java-client ──▶ nostr-java-api + │ + ▼ + nostr-java-mcp ``` -`nostr-java-mcp` is a leaf: it depends on `client` (and transitively on the rest) and -nothing depends on it. This satisfies the Stable Dependencies Principle — the volatile, +`nostr-java-mcp` is a leaf: it depends on `api` (and transitively on the rest) and nothing +depends on it. This satisfies the Stable Dependencies Principle — the volatile, protocol-adapting component depends on the stable ones, never the reverse. +**It depends on `api`, not on `client`.** `nostr-java-api` exists precisely to own the +orchestration every application would otherwise repeat: fan-out publishing with per-relay +outcomes, de-duplicated subscriptions across relays, and NIP-17 delivery to each recipient's +own relays. An MCP server that reached past it to `NostrRelayClient` would rebuild all of +that, and get it subtly wrong in the ways the SDK already learned about. + It is packaged as an executable Spring Boot application **and** a library jar, so it can be run standalone (`java -jar nostr-java-mcp.jar`) or embedded in a host application. @@ -76,11 +89,31 @@ Four layers, each with one reason to change: | --- | --- | --- | | **Transport** | Official MCP SDK server (`McpServer`) over `StdioServerTransportProvider` or `HttpServletStreamableServerTransportProvider`; JSON-RPC framing | The MCP spec changes | | **Tool adapters** | Map MCP tool calls to domain commands; validate arguments; shape results | The tool surface changes | -| **Nostr services** | `PublishEventService`, `QueryEventService`, `SubscriptionService`, `ProfileService`, `DirectMessageService` | Nostr semantics change | -| **SDK facade** | Thin wrappers over `NostrRelayClient`, `Identity`, event factories | The SDK API changes | +| **MCP services** | The behaviour MCP needs and the SDK does not provide: buffering a live subscription for later reads, write confirmation, identity administration | MCP-specific semantics change | +| **SDK** | `NostrClient` from `nostr-java-api`, used directly | The SDK API changes | + +Tool adapters depend on service *interfaces*, so the tool layer is testable with in-memory +fakes and no relay. + +### What this module does not build + +The bottom layer is deliberately thin, because 2.2.0 already owns most of what an earlier +draft of this spec planned to write here: -Tool adapters depend on service *interfaces*, not on `NostrRelayClient`, so the tool layer -is testable with in-memory fakes and no relay. +| Concern | Provided by | So MCP does not | +| --- | --- | --- | +| Connecting to many relays, health, reconnection | `RelayPool` (`nostr-java-client`) | manage connections | +| Publishing to all relays, per-relay outcomes | `PublishResult`, `RelayPublishOutcome` | aggregate results | +| Total-failure signalling | `NoRelayAcceptedException` | invent an error convention | +| One subscription across relays, de-duplicated | `RelaySubscription`, `SubscriptionListener` | merge or de-duplicate streams | +| Re-subscribing a relay that dropped | `RelayPool` background reconnection | replay REQs | +| NIP-17 seal and gift wrap | `Nip17DirectMessageService`, `Nip59GiftWrapper` | implement the envelope | +| Delivering a DM to the recipient's relays | `DirectMessagePublisher` (`nostr-java-api`) | resolve or connect to them | +| Finding a recipient's kind-10050 relay list | `RelayListLookup` (`nostr-java-api`) | query for it | + +What remains genuinely this module's work is everything MCP-shaped: the tool surface, the +keystore and vault, the write guard, identifier parsing, and the buffering that turns a +push-based subscription into something an agent can poll. ### MCP library choice @@ -98,11 +131,38 @@ do not control. - `NostrToolRegistry` — the single place where tools are declared, one class per tool implementing a common `NostrTool` interface (name, JSON schema, `execute`). New tools are added by adding a class, not by editing a switch (Open/Closed). -- `RelayPool` — resolves logical relay names to URLs, owns connection lifecycle and reuse. +- `RelayDirectory` — maps the logical relay names an agent uses (`"read"`, `"write"`, or a + configured alias) to the URIs handed to `NostrClient`. Naming only; the connections + themselves belong to the SDK's `RelayPool`, and reusing that name here would give the + codebase two different `RelayPool` types. - `IdentityVault` — resolves a logical identity name (`"default"`, `"alice"`) to an `Identity`. Private keys are loaded once at startup and never leave the vault (§7.1). -- `SubscriptionRegistry` — owns live subscriptions and their bounded buffers (§6.1). +- `SubscriptionBuffers` — holds the bounded ring buffer behind each live subscription so an + agent can poll for what arrived while it was not looking (§6.1). The subscription itself is + a `RelaySubscription` owned by the SDK. - `WriteGuard` — the policy object consulted before any relay write (see §8). +- `McpDirectMessageService` — the tool-facing seam for DM tools. Named to avoid colliding + with `nostr.encryption.DirectMessageService`, the SDK interface it ultimately calls + through `NostrClient`. +- `RelayConnectionBroker` — shares one websocket per relay across bound processes (§12). + Optional: a single server does not need it, and a deployment that declines it simply opens + its own connections. See §6.3.1 for why it shares transport only. + +### Limitations inherited from the SDK + +Three documented SDK behaviours shape decisions in this module rather than being incidental: + +- **One request in flight per relay.** `NostrRelayClient` serves a single request at a time + and `RelayPool` queues per relay, so throughput to any one relay is bounded by round-trip + latency. This is why `limits.max-events-per-query` and the subscription cap exist: an agent + can otherwise queue enough work behind one slow relay to stall every other tool call. +- **A relay borrowed for a direct message is briefly shared.** Sending a DM connects to the + recipient's relays, and while connected they take part in other operations. Under + single-identity mode this is contained by the process boundary; in the multi-identity + server it is worth knowing that a DM can widen the relay set another tool then publishes to. +- **De-duplication is windowed.** An event whose copies arrive far apart can be delivered + twice. A subscription buffer must therefore tolerate a repeat rather than assume the SDK + guarantees exactly-once over an unbounded period. ## 6. Tool surface (v1) @@ -144,13 +204,17 @@ Query-until-EOSE is not enough: an agent asked to "watch my mentions" needs even arrive after the call returns. MCP has no server-push-into-a-tool-result mechanism, so subscriptions are modelled as **stateful server resources**: -- `nostr_subscribe` opens a REQ through `NostrRelayClient` (which already supports - long-lived subscriptions, see the streaming-subscriptions how-to) and returns a - `subscriptionId`. -- Incoming events land in a **bounded ring buffer** per subscription (default 500 events). - When it overflows the oldest events are dropped and a monotonic `droppedCount` is - incremented, so the agent is told it missed data rather than silently losing it. Nothing - is persisted; a restart drops all subscriptions. +- `nostr_subscribe` calls `NostrClient.subscribe(filters, listener)`, which registers the + filter with every relay in the pool and returns a `RelaySubscription`. The tool returns a + `subscriptionId` naming it. +- The listener writes incoming events into a **bounded ring buffer** per subscription + (default 500 events). When it overflows the oldest events are dropped and a monotonic + `droppedCount` is incremented, so the agent is told it missed data rather than silently + losing it. Nothing is persisted; a restart drops all subscriptions. + + This buffer is not de-duplication. The SDK already delivers each event once however many + relays carry it; the buffer exists because MCP has no way to push into a tool result, so + events arriving between polls must be held somewhere. - Each subscription is also exposed as an MCP resource, `nostr://subscription/{id}`, and the server emits `notifications/resources/updated` when new events arrive. A host that supports resource subscriptions gets push; one that does not can poll @@ -161,24 +225,60 @@ subscriptions are modelled as **stateful server resources**: - Subscriptions have a configurable idle TTL (default 1 hour) after which they are closed and reaped, so an abandoned agent session cannot leak relay connections. Total live subscriptions are capped. -- Reconnection is delegated to `NostrRelayClient`'s retry logic; on reconnect the REQ is - replayed with `since` set to the last received event's timestamp. +- Reconnection is the SDK's job. `RelayPool` retries downed relays on its own schedule and + re-subscribes them from the stored filter, so a relay that drops mid-stream rejoins without + the MCP layer replaying anything. `SubscriptionListener.onRelayFailure` reports the drop, + and `nostr_list_subscriptions` surfaces it so an agent can see a stream degrade. +- `SubscriptionListener.onEndOfStoredEvents` fires once, after every relay has replayed its + backlog or a timeout expires. It is **asynchronous**: `RelayPool.subscribe` returns + immediately, before any stored event or the signal itself has arrived (verified against a + live relay: both counts are zero the instant it returns). + + So `nostr_subscribe` must not pretend history is ready. It returns the `subscriptionId` + together with a `backlogDrained: false`, and `nostr_read_subscription` reports the flag so + an agent can tell "nothing matched yet" from "the backlog is still replaying". A tool that + blocked until EOSE would stall for the backlog timeout on any relay that never answers, + which is exactly the failure the SDK's timeout exists to prevent. ### 6.2 Direct messages and NIP-17 -The SDK today ships NIP-04 (`EncryptedDirectMessage`) and the NIP-44 v2 primitives -(`EncryptedPayloads`, including `getConversationKey`), but **not** NIP-17. NIP-17 needs a -kind-14 chat rumor, sealed in a kind-13 with NIP-44, then gift-wrapped in a kind-1059 signed -by a fresh throwaway key with a randomised `created_at`. +**NIP-17 shipped in the SDK in 2.1.0, so this is no longer a prerequisite.** An earlier +draft of this spec treated it as blocking work against `nostr-java-event`; that work is done +and the module consumes it. + +The layering is worth stating, because it decides what a DM tool actually calls: + +- `Nip59GiftWrapper` (`nostr-java-identity`) implements the three-layer envelope: an unsigned + kind-14 `Rumor`, sealed in a kind-13 signed by the real author, gift-wrapped in a kind-1059 + signed by a single-use key with a randomised `created_at`. +- `Nip17DirectMessageService` composes a `ChatMessage` into one wrap per participant and + reads incoming wraps back. It plans delivery but never sends, because that module holds no + transport. +- `DirectMessagePublisher` (`nostr-java-api`) performs that plan, delivering each wrap to its + own recipient's relays and reporting a `RecipientDeliveryOutcome` per participant. + +So `nostr_send_direct_message` is a thin adapter over `NostrClient.sendDirectMessage`, and +its result maps directly onto the per-recipient outcomes. Two behaviours the tool must +surface rather than hide: + +- A recipient who has published no kind-10050 relay list comes back `UNREACHABLE`. NIP-17 + forbids sending to them, so the message genuinely did not go, and the agent must be able to + tell the user which recipient missed out. +- Every conversation includes the sender, since NIP-17 requires a copy addressed to them. + A one-recipient send therefore reports **two** outcomes, and the tool must not present that + as a partial failure. + + This matters more than it first appears. A sender who has published no kind-10050 list of + their own comes back `UNREACHABLE` for their *own* copy while the actual recipient is + `DELIVERED` (observed against a live relay). Reported naively, "1 of 2 delivered" would tell + the user their message failed when it arrived perfectly well. So the tool reports the + recipients separately from the sender's archival copy, and surfaces a sender-side + `UNREACHABLE` as advice — publish a relay list to keep your own sent messages — rather than + as a delivery error. NIP-04 leaks metadata (both pubkeys and the conversation are visible to every relay) and is -unsuitable as the DM story for a tool an agent drives on a user's behalf. So v1 ships NIP-17 -only, and NIP-04 is not exposed at all. - -The NIP-17 seal/gift-wrap logic is **not** implemented in this module. It belongs in -`nostr-java-event` (and the NIP-44 layer it builds on in `nostr-java-identity`), where every -consumer of the SDK benefits. This makes NIP-17 support a **hard prerequisite** of the DM -phase of the delivery plan, tracked as its own work item against those modules. +unsuitable as the DM story for a tool an agent drives on a user's behalf. It is deprecated in +the SDK and not exposed here at all. ### 6.3 Identity management @@ -248,8 +348,8 @@ nsec is a dead account, and no relay, backup, or protocol can restore it. - Deletion zeroes the in-memory key, removes the keystore entry, and closes any subscription or pending write bound to that alias. - The removal is logged with the public key, so the audit trail outlives the key. -- Under `write-policy: deny` the tool is not registered at all, matching how write tools - behave: a read-only server cannot mutate the keystore either. +- Under `identity-policy: deny` the tool is not registered at all, and `write-policy: deny` + implies that (§12): a read-only server cannot mutate the keystore either. #### Exporting @@ -316,10 +416,10 @@ operation", and it is worth being precise about what does and does not deliver t **Threads do not.** Threads in a JVM share one heap, so every thread can read every other thread's `Identity` object. A thread-per-identity design would give the *appearance* of separation with none of the substance. It is also the wrong concurrency shape: relay work is -I/O-bound and `NostrRelayClient` is already `CompletableFuture`-based, so a dedicated thread -per identity would idle almost always while capping each identity at one in-flight -operation. And it would not remove the lifecycle question, since a thread neither generates -a keypair nor forgets one. +I/O-bound and the SDK already fans out across relays on virtual threads inside `RelayPool`, +so a dedicated platform thread per identity would idle almost always while adding a second, +coarser queue on top of the pool's own per-relay serialisation. And it would not remove the +lifecycle question, since a thread neither generates a keypair nor forgets one. **Processes do.** A separate process has its own address space, its own file handles, and its own OS-level permissions. A compromise or a bug in the process signing for `project-bot` @@ -349,11 +449,30 @@ server, so identities become entries. Each process reads only its own key. The agent sees two clearly-named tool groups and cannot confuse them, because the tools themselves are distinct. -The costs are real and worth stating: N processes, N relay connection pools, N websocket -connections to the same relays, and **no cross-identity operation** — you cannot ask "which -of my accounts was mentioned this week" from a bound server. That query needs the -multi-identity server, which is exactly why both modes exist rather than one replacing the -other. +The costs are real and worth stating: N processes, N relay connection pools, and **no +cross-identity operation** — you cannot ask "which of my accounts was mentioned this week" +from a bound server. That query needs the multi-identity server, which is exactly why both +modes exist rather than one replacing the other. + +The websocket cost is mitigated rather than accepted: bound processes share relay connections +through a `RelayConnectionBroker` (§12), so N identities do not mean N connections to the same +relay. The broker shares transport only. Sharing a `RelayPool` would share subscriptions and +per-relay state too, reintroducing exactly the cross-identity reach that running separate +processes exists to prevent. + +There is a tension here worth naming rather than glossing. Bound processes are separate +address spaces, which is the whole point, so a shared broker cannot be an object they all +reference; it is a separate process they all talk to. That makes it a component with its own +lifecycle, its own failure mode (every identity loses relay access when it dies), and its own +trust question (it sees every bound identity's traffic, though never their keys). It is +therefore **opt-in and not the default**: a handful of identities should just open a handful of +connections, and the broker earns its complexity only where relay-imposed connection limits +actually bite. + +`RelayConnection` (`nostr-java-client`) is the seam it implements, so adopting it changes +configuration rather than code. **Verified by building it**: two independent `RelayPool` +instances, each publishing successfully to a real relay, shared a single websocket, with the +only change being the `RelayConnectionFactory` they were handed. ##### Bootstrapping @@ -395,10 +514,11 @@ Spring Boot properties under `nostr.mcp.*`, overridable by environment variables nostr: mcp: transport: stdio # stdio | http + bind-address: 127.0.0.1 # http only; the transport has no auth of its own (§12) identity: personal # optional: bind to one identity (§6.3.1) keystore: - type: encrypted-file # env | encrypted-file | os-keychain - path: ${HOME}/.nostr-java/keys.jceks + type: os-keychain # env | encrypted-file | os-keychain + path: ${HOME}/.nostr-java/keys.jceks # used by encrypted-file only identities: default: alias: personal # entry in the keystore; never the key itself @@ -415,6 +535,7 @@ nostr: max-events-per-query: 500 query-timeout: 15s write-policy: confirm # deny | confirm | allow + identity-policy: deny # deny | confirm | allow; keystore mutation, separate from writes ``` Keys must never be logged. The startup banner prints public keys only. @@ -431,7 +552,7 @@ how the SDK's tests already pass keys. But the key sits in the process environme any child process, `/proc`, and most crash reporters can read it. Acceptable for a throwaway test identity; the server logs a warning at startup when this backend is active. -**B. `encrypted-file` — a password-protected keystore (default, recommended).** +**B. `encrypted-file` — a password-protected keystore (portable fallback).** A JCEKS/PKCS#12 file at `~/.nostr-java/keys.jceks`, one entry per identity alias, the file encrypted with a passphrase supplied at startup (prompt for stdio, `NOSTR_MCP_KEYSTORE_PASSPHRASE` for headless). Decrypted keys live only inside `IdentityVault`, held as `byte[]`/`char[]` @@ -440,11 +561,12 @@ may be interned. File permissions are checked at startup and the server refuses a world-readable keystore. This is a familiar, portable, dependency-free mechanism and it survives a container restart via a mounted volume. -**C. `os-keychain` — delegate to the platform.** +**C. `os-keychain` — delegate to the platform (default).** macOS Keychain, Windows DPAPI, or Secret Service / `libsecret` on Linux, reached through a -small adapter. Best available protection on a developer desktop and no passphrase to manage, -but it is platform-specific, awkward in containers, and needs a native dependency. Offered -as an option, not the default. +small adapter. Best available protection on a developer desktop, and no passphrase to manage, +so it does not block unattended startup the way a prompt does. It is platform-specific and +awkward in containers, which is why `encrypted-file` remains the documented choice there and +is selected explicitly in the compose file rather than inherited. Regardless of backend: @@ -471,8 +593,11 @@ than failing confusingly. Ships as an executable Spring Boot jar that is **both** the MCP server and the key-admin CLI (§6.3.1), a `Dockerfile` (distroless JRE 21 base, non-root user), and a `docker-compose.yml` that runs the server in HTTP transport mode alongside the existing test -relay container, with the keystore mounted read-only as a volume and the passphrase supplied -as a secret. The compose file also demonstrates the bound-container pattern: one service per +relay container. Its port mapping is bound to the host loopback (`127.0.0.1:PORT:PORT`, not +`PORT:PORT`), since a container publishing a port reaches every interface by default and the +transport carries no credentials of its own (§8, §12). The keystore is mounted read-only as a +volume and the passphrase supplied as a secret. The compose file sets `keystore.type: encrypted-file` explicitly, because the +`os-keychain` default (§12) has nothing to talk to inside a container. The compose file also demonstrates the bound-container pattern: one service per identity, each with `--nostr.mcp.identity` set and only its own key readable. Per repo convention the compose file is verified with `docker-compose build` in CI. The stdio transport is documented as a bare `java -jar` invocation, since an MCP host launches the @@ -491,8 +616,12 @@ guarded action. - `write-policy: allow` — writes proceed directly, for trusted automation. - Rate limits per identity and per relay, enforced in `WriteGuard`. - Identity removal and backup export are guarded by the same two-step confirmation, and - neither is registered under `write-policy: deny` or in single-identity mode (§6.3, + neither is registered under `identity-policy: deny` or in single-identity mode (§6.3, §6.3.1). +- `identity-policy` governs keystore mutation separately from `write-policy` (§12), so a + deployment can let an agent post without letting it create or destroy keys. It defaults to + the more restrictive of the two, and `write-policy: deny` implies no mutation, so the safe + combination needs no configuration. - Key isolation between identities is a **process** boundary, not a thread or a check (§6.3.1). Deployments that need it run one bound server per identity. - No tool accepts private key material as an argument, and no tool returns it (§6.3, §7.1). @@ -501,6 +630,12 @@ guarded action. - DM decryption is opt-in per identity, since it exposes private correspondence to the model. NIP-17's gift wrapping means the relay cannot see the correspondents, but the MCP host can, so this stays an explicit per-identity grant. +- **The HTTP transport has no authentication of its own** (§12) and binds to `127.0.0.1` by + default. Anything that can reach it can publish as every identity the server holds, so + exposing it beyond the loopback interface requires a reverse proxy with real credentials in + front. The server logs a warning at startup when `bind-address` is not a loopback address, + for the same reason the `env` keystore backend warns: a weaker choice should be noisy rather + than silent. ## 9. Error handling @@ -509,8 +644,19 @@ the model can act on, never as stack traces. Categories mirror the SDK's excepti hierarchy: `RELAY_UNREACHABLE`, `RELAY_REJECTED`, `INVALID_ARGUMENT`, `IDENTITY_UNKNOWN`, `WRITE_FORBIDDEN`, `TIMEOUT`, `SUBSCRIPTION_UNKNOWN`, `SUBSCRIPTION_LIMIT_REACHED`, `KEYSTORE_LOCKED`, `IDENTITY_AMBIGUOUS`, `ALIAS_IN_USE`, `NO_BACKUP_EXISTS`, -`MUTATION_UNSUPPORTED`, `INPUT_REQUIRED`. Partial success on multi-relay publish is a -success with a per-relay result list, not an error. +`MUTATION_UNSUPPORTED`, `INPUT_REQUIRED`. + +Publishing maps onto the SDK's own distinction rather than inventing one: + +| SDK outcome | MCP result | +| --- | --- | +| `PublishResult` with at least one `ACCEPTED` | Success, carrying the per-relay list | +| `NoRelayAcceptedException` | Error `RELAY_REJECTED`, with the attached `PublishResult` rendered so the agent sees each relay's reason | + +Partial success is therefore a success with a per-relay result list, and the only publish +failure is the one where the event reached nobody. Reporting a partial success as an error +would push agents to retry writes that already landed, which on a public and irreversible +medium is worse than the original problem. ## 10. Testing strategy @@ -521,9 +667,12 @@ success with a per-relay result list, not an error. or error message can contain a private key, and that no tool's input schema accepts one; keystore permission and passphrase-failure paths; identity removal refused without a backup; `IDENTITY_AMBIGUOUS` raised rather than a key guessed. -- **Integration**: the full server over an in-process MCP client against a stub relay - (reusing the existing Docker relay harness), asserting round trips for publish, query, - a live subscription receiving an event published mid-test, and a NIP-17 DM round trip. +- **Integration**: the full server over an in-process MCP client against a real relay, + reusing the harness from `NostrClientRoundTripIT` (Testcontainers, held until the relay has + proved it can store an event), asserting round trips for publish, query, a live + subscription receiving an event published mid-test, and a NIP-17 DM round trip. Follow that + test's readiness discipline: a relay that accepts connections is not necessarily one that + answers, and mistaking the two produces failures that look like client bugs. - **Contract**: every registered tool's JSON schema is validated, and a golden-file test pins the tool list so accidental surface changes are visible in review. Separate golden files per mode (multi-identity, single-identity, `write-policy: deny`) so tool @@ -531,14 +680,19 @@ success with a per-relay result list, not an error. - **Isolation**: a single-identity server started with `identity: personal` exposes no lifecycle tools, accepts no `identity` argument, and never decrypts another alias's keystore entry. +- **Spec conformance**: the SDK behaviours this document depends on are asserted against a + live relay in `McpSpecAssumptionsIT` (`nostr-java-api`), so a change in the SDK that + invalidates a design decision here fails a build rather than being discovered during + implementation. It covers: publish returning per-relay outcomes; total failure throwing with + the result attached; `subscribe` returning before the backlog drains; an event published + mid-subscription reaching the listener; `EOSE` firing exactly once; a kind-10050 lookup + resolving; a one-recipient DM reporting two outcomes; a recipient without a relay list + reported `UNREACHABLE`; and `publishAs` signing as the named identity. - **Packaging**: `docker-compose build` runs in CI. - Run with `mvn -q verify` from the repository root as usual. ## 11. Delivery plan -0. **Prerequisite, in `nostr-java-event`/`nostr-java-identity`**: NIP-17 support — kind-14 - rumor, kind-13 seal, kind-1059 gift wrap over the existing NIP-44 primitives. Tracked - separately; blocks phase 5 only, so the rest can proceed in parallel. 1. Module skeleton, POM, BOM entry, official MCP SDK on stdio, `IdentityVault` and `IdentityStore` with the `encrypted-file` keystore, the **CLI** lifecycle commands (§6.3.1), single-identity mode, and `nostr_list_relays` — proves the wiring end to end @@ -547,39 +701,140 @@ success with a per-relay result list, not an error. 3. Write path behind `WriteGuard`: `nostr_publish_note`, `nostr_publish_event`, `nostr_update_profile`. Identity lifecycle **tools** (§6.3) land here too, for the multi-identity administration case. -4. Subscriptions: `SubscriptionRegistry`, the four subscription tools, resource +4. Subscriptions: `SubscriptionBuffers`, the four subscription tools, resource notifications, TTL reaping. -5. Social layer: threads, contacts, NIP-17 direct messages. +5. Social layer: threads, contacts, and NIP-17 direct messages over + `NostrClient.sendDirectMessage` / `readDirectMessage`. Direct messages need no SDK work, + since NIP-17 landed in 2.1.0 and delivery in 2.2.0. Contacts do: a `ContactList` value type + over kind-3 belongs in `nostr-java-event` (§12) and is this module's only SDK prerequisite. 6. HTTP transport with per-session identity binding, `Dockerfile` and `docker-compose.yml` (including a profile showing one bound container per identity), prompts, and documentation (a how-to for wiring the server into an MCP host, covering both modes). -## 12. Open questions - -- Which keystore backend is the default on a fresh install: prompt-for-passphrase - (`encrypted-file`) is safest but blocks unattended startup. Is a passphrase-less - `os-keychain` default better for desktop users? -- With the CLI available, should the identity lifecycle **tools** exist at all, or is - agent-driven key administration a capability worth omitting entirely? -- Should single-identity mode be the documented default in the how-to, with the - multi-identity server presented as the advanced case? -- Do bound processes need a shared relay-connection broker to avoid N websocket - connections to the same relay, or is that premature for the expected handful of - identities? -- Should `write-policy: confirm` tokens expire, and after how long? -- Should identity mutation have its own policy switch (`identity-policy`) separate from - `write-policy`, so an agent can be allowed to post but not to touch the keystore? -- Should the server auto-create a `default` identity on first run when the keystore is - empty, or refuse to start until one exists? -- Does the HTTP transport need authentication of its own (bearer token) in v1, or is it - documented as bind-to-localhost only? -- Does NIP-17 support land as a contribution to this repo, or is it already planned - upstream in the BOM's event module? +## 12. Resolved decisions (round two) + +The questions this section previously listed have been answered. They are kept as decisions +with their reasoning, because the reasoning is what a reader needs when the trade-off resurfaces. + +### Keystore default: `os-keychain` + +The default on a fresh install is the platform keychain, not the encrypted file. It needs no +passphrase, so it does not block unattended startup, and on a desktop it is the strongest +protection available. `encrypted-file` remains the portable fallback and the sensible choice in +a container, where there is no keychain to talk to; §7.2's compose file therefore selects it +explicitly rather than inheriting the default. + +### Identity lifecycle tools: keep them + +The tools stay, alongside the CLI. An agent asked to "make me a throwaway account for this +project" should be able to do it, and the design already removes the reason to withhold the +capability: no tool accepts or returns key material, creation is reversible by discarding the +key, and the two genuinely irreversible operations (export, remove) are guarded by two-step +confirmation. Withholding the tools would not add safety, only friction, since the CLI can do +the same things with less oversight from the model's own audit trail. + +Single-identity mode still unregisters them (§6.3.1): a process bound to one key operates that +key and does not administer a keystore. + +### Documented default: single-identity mode + +The how-to leads with one bound process per identity, and presents the multi-identity server as +the advanced case. It is the safer default, it matches how MCP hosts are configured anyway, and +the cross-identity query that justifies the multi-identity server is a genuinely advanced need. + +### Bound processes share relay connections + +Bound processes can share a `RelayConnectionBroker` rather than each opening its own websocket +to the same relay. Without it, N identities means N connections per relay, which relays +penalise and which scales badly exactly where this deployment is recommended. + +The broker is a connection-level concern only. It must not become a shared `RelayPool`: the +pool holds subscriptions and per-relay state, and sharing that across bound processes would +reintroduce the cross-identity leakage that process isolation exists to prevent (§6.3.1). What +is shared is the transport beneath it, implementing `RelayConnection`. + +It is **opt-in, not the default**, because bound processes are separate address spaces: a +shared broker is another process, not an object, and it brings its own lifecycle, a single +point of failure for every identity's relay access, and a component that sees all their traffic. +A handful of identities should open a handful of connections. The broker earns that complexity +only where a relay's connection limits actually bite. + +### Confirmation tokens do not expire + +A `write-policy: confirm` token stays valid until it is used or the server restarts. Expiry +would add a failure mode without adding safety: the token's purpose is to make a hallucinated +write a no-op by requiring a second, deliberate call, and that property does not decay with +time. An agent that comes back to a token an hour later is still confirming the same event it +was shown. + +### Identity mutation gets its own policy switch + +`identity-policy` is separate from `write-policy`, so an agent can be allowed to post while +being unable to touch the keystore. The two capabilities have genuinely different risk profiles: +publishing is public and irreversible but bounded to one event, while removing a key destroys +every future use of an account. Collapsing them would force a deployment to choose between a +useful agent and a protected keystore. + +`identity-policy` defaults to the more restrictive of the two, and `write-policy: deny` still +implies no keystore mutation, so the safe combination remains the default without configuration. + +### No auto-created identity on first run + +A server started with an empty keystore refuses to start and says how to create an identity, +rather than generating a `default` silently. A key created without the user knowing is a key +they have no backup of, and the first they would learn of it is when something published under +it. The CLI (§6.3.1) makes the explicit path a single command. + +### HTTP transport authentication: not in v1 + +The HTTP transport is documented as bind-to-localhost only, with no bearer token of its own in +v1. Adding half an auth story is worse than deferring it deliberately: a token in a config file +protects little, and the deployments that genuinely need remote access need a reverse proxy +with real credentials in front of them regardless. + +The constraint is stated where it can be acted on rather than only here: `bind-address` +defaults to `127.0.0.1` in the configuration (§7), the safety model lists an exposed transport +as a threat and warns at startup on a non-loopback bind (§8), and the compose file publishes +its port to the host loopback only (§7.2), since a container reaches every interface by +default. A deferral that lives in one section of a design document is not a deferral, it is a +trap. + +### kind-3 contact lists need a new SDK type + +**Verified against 2.2.0:** no `ContactList` type exists. `Kinds.CONTACT_LIST = 3` is defined, +but nothing models the event, so `nostr_get_contacts` has nothing to call. + +The type belongs in `nostr-java-event`, not here, for the same reason NIP-17 did: every +consumer benefits, and an MCP module parsing `p` tags inline would be the only place in the +codebase that knows how a contact list is shaped. `DirectMessageRelayList` is the pattern to +follow, being a value type over a tag-carrying replaceable event with `from(GenericEvent)` and +`toEvent()`. + +**Verified by prototyping it**: a `ContactList` written to that pattern, reading `p` tags and +rendering them back, round-tripped through a real relay via `NostrClient` — published, matched +by an author-and-kind filter, and recovered with both contacts intact — and its kind guard +rejected a kind-1 event. The prerequisite is genuinely small: one value type, no new +infrastructure. + +This makes a small `ContactList` addition to `nostr-java-event` a **prerequisite of the social +phase** (§11 phase 5), and the only SDK work this module now requires. + +## Tickets + +The delivery plan above is broken into twelve tracer-bullet tickets under +`.scratch/nostr-java-mcp/issues/`, each declaring what blocks it. Two can start immediately: +the `ContactList` prerequisite in `nostr-java-event`, and the module skeleton with its stdio +transport. ## Related documents - [architecture.md](architecture.md) — existing module architecture and data flow -- [../howto/streaming-subscriptions.md](../howto/streaming-subscriptions.md) — the - subscription mechanics the query tools build on +- [../howto/multi-relay-publishing.md](../howto/multi-relay-publishing.md) — the + `NostrClient` surface this module adapts, including per-relay publish outcomes and + de-duplicated subscriptions +- [../reference/nostr-java-api.md](../reference/nostr-java-api.md) — signatures for + `NostrClient`, `RelayPool`, `PublishResult`, and the known limitations they carry +- [../howto/streaming-subscriptions.md](../howto/streaming-subscriptions.md) — single-relay + subscription mechanics - [../operations/configuration.md](../operations/configuration.md) — configuration conventions this module follows diff --git a/docs/howto/ci-it-stability.md b/docs/howto/ci-it-stability.md index a08c5cb8..4c4ec83c 100644 --- a/docs/howto/ci-it-stability.md +++ b/docs/howto/ci-it-stability.md @@ -9,7 +9,7 @@ This how‑to explains how we keep CI green across environments and how to run i ## CI Layout - Matrix build on Java 21 and 17 - - JDK 21: full build without Docker (`-DnoDocker=true`) + - JDK 21: full build without Docker (`-Pno-docker`) - JDK 17: POM validation only (project targets 21) - Separate IT job on pushes uses Docker/Testcontainers to run end‑to‑end tests @@ -22,7 +22,7 @@ See `.github/workflows/ci.yml` for the configuration and artifact uploads (Suref ``` - Unit tests only (no Docker): ```bash - mvn -DnoDocker=true clean verify + mvn -Pno-docker clean verify ``` - Using helper script: ```bash diff --git a/docs/howto/multi-relay-publishing.md b/docs/howto/multi-relay-publishing.md new file mode 100644 index 00000000..1aec96e6 --- /dev/null +++ b/docs/howto/multi-relay-publishing.md @@ -0,0 +1,186 @@ +# Publish and subscribe across many relays + +Nostr is a multi-relay protocol: an event is worth publishing to several relays, and worth +reading from several at once. This guide shows how to do both with `NostrClient`, and how to +send a private direct message. + +## Add the module + +```xml + + xyz.tcheeric + nostr-java-api + 2.3.1 + +``` + +## Create a client + +Name your identity and your relays once: + +```java +Identity identity = Identity.generateRandomIdentity(); + +try (NostrClient nostr = NostrClient.builder() + .identity(identity) + .relays("wss://relay.398ja.xyz", "wss://nos.lol") + .build()) { + // ... +} +``` + +The client connects to what it can. A relay that is unreachable is marked down and retried in +the background, so one dead relay never stops your application starting. + +## Publish a note + +```java +PublishResult result = nostr.publishTextNote("Hello Nostr!"); +``` + +The event is signed with your identity and sent to every relay at once. + +### Read the result + +Relays disagree, so publishing has no single verdict: + +```java +result.getAcceptingRelays(); // relays that stored the event +result.isAcceptedByAllRelays(); // true only when every relay accepted + +for (RelayPublishOutcome failure : result.getFailures()) { + System.out.println(failure.relayUri() + ": " + failure.status() + + failure.findReason().map(reason -> " (" + reason + ")").orElse("")); +} +``` + +A relay may reject with a reason such as `blocked: pubkey banned`, time out, or be unreachable. +All three are ordinary data. + +**Total failure throws.** If no relay stored the event, `publish` raises +`NoRelayAcceptedException` rather than returning, so an event that reached nobody cannot be +mistaken for a published one. The exception carries the same `PublishResult`: + +```java +try { + nostr.publishTextNote("Hello Nostr!"); +} catch (NoRelayAcceptedException e) { + e.getPublishResult().getFailures().forEach(System.out::println); +} +``` + +## Subscribe across every relay + +```java +try (RelaySubscription subscription = nostr.subscribe( + List.of(EventFilter.builder().kind(1).build()), + event -> System.out.println(event.getContent()))) { + // events arrive until the subscription is closed +} +``` + +Each event is delivered **once**, however many relays hold it, and arrives already parsed as a +`GenericEvent`. Closing the subscription stops delivery from every relay. + +### React to the backlog and to failures + +A relay answers a subscription with its stored events, then signals that its backlog is drained, +then streams live ones. Implement `SubscriptionListener` to see all three: + +```java +nostr.subscribe(filters, new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + render(event); + } + + @Override + public void onEndOfStoredEvents() { + hideLoadingSpinner(); // fires once, after every relay has replayed + } + + @Override + public void onRelayFailure(String relayUri, Throwable failure) { + log.warn("Relay {} dropped: {}", relayUri, failure.getMessage()); + } +}); +``` + +`onEndOfStoredEvents` fires exactly once, when every relay has replayed or a timeout expires, so +one unresponsive relay cannot leave your interface loading forever. Stored events always arrive +before it. + +A relay that drops mid-stream is reported and re-subscribed automatically when it reconnects, so +a long-lived subscription repairs itself instead of quietly shrinking. + +## Send a private direct message + +```java +RecipientDeliveryOutcome outcome = nostr.sendDirectMessage(recipientKey, "dinner at eight"); + +if (!outcome.isDelivered()) { + System.out.println("not delivered: " + outcome.findReason().orElse("unknown")); +} +``` + +The message is gift-wrapped per NIP-17 and delivered to the **recipient's** relays, discovered +from their published kind-10050 list. Relays connected only for the delivery are released +afterwards. + +A recipient who has published no relay list is reported `UNREACHABLE` rather than silently +skipped: NIP-17 treats that as declining private messages. + +For several recipients, each is reported separately, because a group message can partly succeed: + +```java +List outcomes = + nostr.sendDirectMessage(List.of(alice, bob), "dinner at eight"); +``` + +Every conversation includes the sender, since NIP-17 requires a copy addressed to them so they +can read their own sent messages. + +### Read incoming messages + +```java +nostr.subscribe( + List.of(EventFilter.builder().kind(1059).build()), + wrap -> System.out.println(nostr.readDirectMessage(wrap).getContent())); +``` + +Only wraps addressed to your identity can be opened; attempting to read another's will fail, +which is the point of the scheme. + +## Reach further down + +The facade is not a wall. Events are ordinary `GenericEvent` values, and the relay pool is +available for anything the client does not cover: + +```java +RelayPool pool = nostr.getRelayPool(); +pool.addRelay("wss://relay.temporary"); +pool.getConnectionState("wss://nos.lol"); +``` + +## Know the edges + +Three behaviours are deliberate, and each will look like a bug if you meet it unprepared. + +**Throughput to one relay is bounded.** A relay connection serves one request at a time, so the +pool queues operations per relay. Publishing from ten threads to the same relay is as fast as +that relay's round-trip latency allows, no faster. Fan-out across different relays is fully +concurrent, so adding relays scales; hammering one does not. + +**A relay borrowed for a direct message is briefly shared.** Delivering to someone connects to +*their* relays, and while connected those relays take part in other operations too. They are +released once the delivery finishes. + +**De-duplication forgets.** The window that suppresses duplicates is bounded, so an event whose +copies arrive far apart can reach you twice. The default is sized well beyond any realistic +spread between relays; raise it with the three-argument `subscribe` if your workload needs to. + +## Related + +- [Private direct messages](private-direct-messages.md) — composing NIP-17 messages directly +- [Streaming subscriptions](streaming-subscriptions.md) — single-relay subscriptions +- [Architecture](../explanation/architecture.md) — how the modules fit together diff --git a/docs/howto/private-direct-messages.md b/docs/howto/private-direct-messages.md new file mode 100644 index 00000000..65dfbaa8 --- /dev/null +++ b/docs/howto/private-direct-messages.md @@ -0,0 +1,177 @@ +# Send Private Direct Messages + +Navigation: [Docs index](../README.md) · [Getting started](../GETTING_STARTED.md) · [API how-to](use-nostr-java-api.md) · [Streaming subscriptions](streaming-subscriptions.md) · [Custom events](custom-events.md) + +This guide shows how to send and read private direct messages with **nostr-java**, using +[NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) gift-wrapped messaging. + +## What NIP-17 hides + +A NIP-04 direct message hides only its text. The sender, the recipient, the exact time, and +the number of messages exchanged are all public on every relay that carries the event, so an +observer learns who talks to whom and when. + +NIP-17 hides all of it, using three layers defined by +[NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md): + +| Layer | Kind | Signed by | What it reveals | +| --- | --- | --- | --- | +| Rumor | 14 | nobody | the message, once decrypted | +| Seal | 13 | the real sender | who wrote it, to the recipient only | +| Gift wrap | 1059 | a single-use key | that *someone* sent *something* to a recipient | + +The SDK builds and opens all three. You work with a `ChatMessage`, and never handle a seal, +an ephemeral key, or a conversation key yourself. + +## Prerequisites + +```xml + + xyz.tcheeric + nostr-java-identity + +``` + +## Publish where you receive messages + +Before anyone can message you, publish a kind-10050 list naming the relays you read. NIP-17 +says a sender **must not** deliver to any other relay, and **must not** send at all to +someone who has published no list. Without this, nobody can reach you. + +```java +Identity alice = Identity.create(privateKeyHex); + +DirectMessageRelayList inbox = new DirectMessageRelayList( + alice.getPublicKey(), + List.of(new Relay("wss://inbox.nostr.wine")), + Instant.now().getEpochSecond()); + +GenericEvent inboxEvent = inbox.toEvent(); +alice.sign(inboxEvent); +// publish inboxEvent through your relay client +``` + +Keep the list short, one to three relays, and publish it to as many relays as you can so +senders can find it. + +## Send a message + +```java +Nip17DirectMessageService messages = new Nip17DirectMessageService(alice); + +ChatMessage message = messages.message() + .to(bobPublicKey) + .subject("Dinner") + .content("Are you going to the party tonight?") + .build(); + +List wraps = messages.compose(message); +``` + +`compose` returns **several events, not one**. There is no shared envelope in NIP-17: each +participant gets their own separately encrypted copy, which is what keeps the conversation's +membership private. + +> **One of those copies is addressed to you.** A sender who publishes only their recipients' +> copies can never read the conversation back, because they cannot decrypt a wrap addressed +> to someone else. Publish every event `compose` returns, including your own. + +## Send to the right relays + +`compose` gives you events but not destinations. Use `planDelivery` to pair each copy with +the relays its recipient nominated: + +```java +DirectMessageRelayLookup relayLists = pubkey -> lookUpKind10050For(pubkey); + +for (MessageDelivery delivery : messages.planDelivery(message, relayLists)) { + if (delivery.isDeliverable()) { + publish(delivery.giftWrap(), delivery.relays()); + } else { + log.info("{} is not accepting private messages", delivery.recipient()); + } +} +``` + +An unreachable recipient still appears in the plan, carrying no event. That is deliberate: +omitting them silently is how a message goes half-delivered without anyone noticing, and it +distinguishes "this person does not accept private messages" from "the relay was down". + +You supply the lookup, backed by a relay query or a cache. Message composition itself never +touches the network. + +## Read your messages + +Subscribe to kind 1059 events tagged with your public key, then open each one: + +```java +Nip17DirectMessageService messages = new Nip17DirectMessageService(bob); + +for (GenericEvent giftWrap : incomingEvents) { + try { + ChatMessage received = messages.read(giftWrap); + System.out.printf("%s: %s%n", received.getSender(), received.getContent()); + } catch (GiftWrapException notForUs) { + // Expected: a kind-1059 subscription also delivers wraps we cannot open. + } +} +``` + +**Catch and continue.** A kind-1059 subscription delivers wraps addressed to other people, +and possibly malformed ones. Abandoning the batch on the first failure lets one unopenable +event stall an entire conversation. + +The sender reported by `read` is authenticated: the seal's signature is verified and its +author is checked against the rumor's before the message is returned. Because a rumor is +unsigned, that seal signature is the only evidence of who wrote the message. + +## Reply to a message + +```java +ChatMessage reply = messages.message() + .to(received.getSender()) + .inReplyTo(receivedEventId) + .content("Yes, see you at eight") + .build(); +``` + +## Group conversations + +Add more recipients. The participants define the conversation, so adding or removing one +starts a *different* conversation with its own history. + +```java +ChatMessage groupMessage = messages.message() + .to(bobPublicKey) + .to(carolPublicKey) + .content("Dinner at eight?") + .build(); +``` + +Every participant needs their own encrypted copy, so cost grows with group size. NIP-17 +advises finding another scheme beyond about ten participants. + +## Ephemeral messages + +For real-time chat that relays should not store, wrap in kind 21059 instead: + +```java +DirectMessageService liveChat = new Nip17DirectMessageService( + alice, new Nip59GiftWrapper(alice, Kinds.EPHEMERAL_GIFT_WRAP)); +``` + +## What the SDK does not do + +- **Publishing and subscribing.** These types produce and consume events; routing them is + the caller's job, using `NostrRelayClient`. +- **Storing messages.** There is no inbox. Decide what to keep. +- **NIP-42 AUTH.** Relays are advised to serve kind-1059 events only to their addressee, + behind authentication. Delivery from such relays needs AUTH support in your client. + +## Related + +- [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) — private direct messages +- [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) — gift wrap +- [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) — the encryption underneath +- [Streaming subscriptions](streaming-subscriptions.md) — receiving events as they arrive +- [NIP-17 implementation spec](../explanation/nip-17-direct-messages-spec.md) — design notes diff --git a/docs/howto/run-the-mcp-server.md b/docs/howto/run-the-mcp-server.md new file mode 100644 index 00000000..c8a03d41 --- /dev/null +++ b/docs/howto/run-the-mcp-server.md @@ -0,0 +1,253 @@ +# Run the Nostr MCP server + +This guide shows how to run `nostr-java-mcp` so an LLM agent can use Nostr, how to give it a +signing key, and how to choose how much freedom it has. You need Java 21 and an MCP host such +as Claude Desktop or an IDE agent. + +## Build the jar + +The server ships as one self-contained jar, which is both the MCP server and the command-line +tool for managing its keys: + +```bash +git clone https://github.com/tcheeric/nostr-java.git +cd nostr-java +mvn -pl nostr-java-mcp -am package -DskipTests +``` + +The jar lands at `nostr-java-mcp/target/nostr-java-mcp--runnable.jar`. The +`-runnable` suffix matters: the plain `nostr-java-mcp-.jar` beside it holds only this +module's classes and will not start on its own. The examples below shorten the path to +`nostr-java-mcp.jar`; substitute your real one, or copy it somewhere convenient: + +```bash +cp nostr-java-mcp/target/nostr-java-mcp-*-runnable.jar ~/nostr-java-mcp.jar +``` + +## Create a key first + +The server signs as identities held in its own keystore. Create one from the command line +rather than through the agent, so key administration stays with you: + +```bash +java -jar nostr-java-mcp.jar keygen personal +``` + +This prints the new public key and stores the private key in your OS keychain. The private key +is never printed and never reaches the agent. + +Other commands: `list`, `import ` (reads the key from standard input), and +`remove `. + +## Wire it into your MCP host + +An MCP host launches the server itself and speaks to it over standard input and output. Bind +each server to one identity, which is both the safer arrangement and the one that fits how +hosts are configured anyway: + +```json +{ + "mcpServers": { + "nostr-personal": { + "command": "java", + "args": ["-jar", "/path/to/nostr-java-mcp.jar", "-Dnostr.mcp.identity=personal"] + } + } +} +``` + +Binding does more than express a preference. Only that one key is decrypted, so another +identity's key is absent from the process rather than merely out of policy, and the `identity` +argument disappears from every signing tool: there is nothing to name, so nothing to name +wrongly. A bound server also registers no keystore-mutating tools, and refuses to start if its +identity does not exist. + +Add one entry per identity, and the agent sees two clearly-named tool groups it cannot confuse: + +```json +{ + "mcpServers": { + "nostr-personal": { "command": "java", "args": ["-jar", "nostr-java-mcp.jar", "-Dnostr.mcp.identity=personal"] }, + "nostr-project-bot": { "command": "java", "args": ["-jar", "nostr-java-mcp.jar", "-Dnostr.mcp.identity=project-bot"] } + } +} +``` + +### The unbound server + +Omitting `identity` gives one server holding every key. You need this to administer the keystore +through tools, and for questions no bound server can answer, such as "which of my accounts was +mentioned this week". The cost is that the agent chooses which identity signs, so the +wrong-account risk is guarded rather than removed: where several identities exist and none is +the default, signing fails rather than guessing. + +The server starts with `write-policy: confirm` either way, so the agent must confirm before +anything is published. + +## What the agent is taught + +The server ships three guided prompts, which hosts surface as slash-commands or similar: + +| Prompt | What it teaches | +| --- | --- | +| `compose-note` | Draft, show the user, then publish with the confirmation token | +| `catch-up-feed` | Read the follow list first, then query those authors | +| `watch-mentions` | Subscribe, and distinguish "still replaying" from "nothing matched" | + +They exist because a tool surface with no guidance makes a model explore by trial and error, +and on a public, permanent medium the mistakes are visible to everyone. + +It can also read `nostr://identity/{alias}` and `nostr://relay/{name}` as resources, so a host +can put the server's own configuration into context without spending a tool call on it. + +## Choosing how much freedom the agent has + +Publishing to Nostr is public and cannot be reliably undone, so writes are guarded: + +| Setting | Effect | +| --- | --- | +| `-Dnostr.mcp.write-policy=deny` | No write tool is registered. A read-only server. | +| `-Dnostr.mcp.write-policy=confirm` | Default. The agent previews, then publishes with a token. | +| `-Dnostr.mcp.write-policy=allow` | Writes proceed directly. For trusted automation. | + +`-Dnostr.mcp.identity-policy` governs the keystore separately and never grants more than +`write-policy` does, so a read-only server also cannot create or destroy keys. + +## Reading private messages + +Reading direct messages brings private correspondence into the model's context, and therefore +into your host's conversation log and probably a third-party inference API. It is off by +default and enabled per identity: + +```bash +java -jar nostr-java-mcp.jar -Dnostr.mcp.dm.decrypt-for=personal +``` + +Sending messages needs no such flag, because sending discloses nothing you did not write. + +## Run it over HTTP + +For a hosted deployment where the host does not launch the process: + +```bash +java -jar nostr-java-mcp.jar -Dnostr.mcp.transport=http -Dnostr.mcp.port=8080 +``` + +> **The HTTP transport has no authentication of its own.** Anything that can reach it can +> publish as every identity the server holds and read every message it is allowed to decrypt. +> It binds `127.0.0.1` by default for that reason, and logs a warning when configured +> otherwise. If you need to reach it from another machine, put a reverse proxy with real +> credentials in front of it. Do not expose the port directly. + +## Run it in a container + +The image is built from `nostr-java-mcp/Dockerfile` and runs the HTTP transport as a non-root +user on a distroless base: + +```bash +docker build -f nostr-java-mcp/Dockerfile -t nostr-java-mcp . +``` + +`nostr-java-mcp/docker-compose.yml` runs it alongside a local relay. Create a keystore first +and put it in `nostr-java-mcp/keys/`, then: + +```bash +export NOSTR_MCP_KEYSTORE_PASSPHRASE='your passphrase' +docker compose -f nostr-java-mcp/docker-compose.yml up mcp +``` + +The keystore is mounted read-only, and `keystore.type` is `encrypted-file` because there is no +OS keychain inside a container. + +> Note the `127.0.0.1:` prefix on every published port. Without it Docker publishes to **every** +> interface, which bypasses the server's own loopback default and exposes an unauthenticated +> MCP endpoint to your network. Keep it. + +For one container per identity, each unlocking only its own key: + +```bash +docker compose -f nostr-java-mcp/docker-compose.yml --profile bound up +``` + +## Configuring from the environment + +Every setting can be given as an environment variable instead of a system property, which is +how the container is configured. Uppercase the name and replace dots and hyphens with +underscores: + +| Setting | Environment variable | +| --- | --- | +| `write-policy` | `NOSTR_MCP_WRITE_POLICY` | +| `bind-address` | `NOSTR_MCP_BIND_ADDRESS` | +| `relays.read` | `NOSTR_MCP_RELAYS_READ` | +| `limits.max-events-per-query` | `NOSTR_MCP_LIMITS_MAX_EVENTS_PER_QUERY` | + +The encrypted-file keystore reads its passphrase from `NOSTR_MCP_KEYSTORE_PASSPHRASE`. + +## Configuration reference + +All settings are `nostr.mcp.*` system properties, or the same name in the environment. + +| Setting | Default | Purpose | +| --- | --- | --- | +| `transport` | `stdio` | `stdio` or `http` | +| `bind-address` | `127.0.0.1` | HTTP only; where to listen | +| `port` | `8080` | HTTP only | +| `relays.read` | public relays | Comma-separated relay URIs to read from | +| `relays.write` | the read set | Relay URIs to publish to | +| `identity` | none | Bind this server to one alias | +| `identity.default` | the only identity | Which identity signs when none is named | +| `keystore.type` | `os-keychain` | `os-keychain`, `encrypted-file` or `env` | +| `keystore.path` | `~/.nostr-java/keys.p12` | Encrypted-file keystore location | +| `identities` | discovered | Aliases to look for, for backends that cannot list themselves | +| `write-policy` | `confirm` | `deny`, `confirm` or `allow` | +| `identity-policy` | `confirm` | Keystore mutation, capped by `write-policy` | +| `dm.decrypt-for` | none | Aliases whose messages may be decrypted | +| `limits.max-events-per-query` | `500` | Most events one query returns | +| `limits.query-timeout` | `15s` | How long a query waits | +| `limits.writes-per-minute` | `10` | Per-identity publishing rate limit | +| `limits.max-subscriptions` | `20` | Open subscriptions allowed | +| `limits.subscription-buffer` | `500` | Events held per subscription between reads | +| `limits.subscription-idle-timeout` | `1h` | When an unread subscription is closed | + +## Limits worth knowing about + +These are properties of the underlying SDK and the protocol, not settings you can tune away. + +- **Throughput is per relay.** Each relay connection serves one request at a time, so many + concurrent queries against the same relay queue behind each other. This is why queries are + bounded by `limits.query-timeout`: an unbounded one would stall every other tool call. +- **De-duplication is windowed.** The SDK delivers each event once however many relays carry + it, but over a bounded window. A relay replaying an old event long afterwards can arrive + again; subscription buffers drop the repeat, and a query may show it. +- **Subscriptions do not survive a restart.** Nothing is persisted. If the server restarts, open + subscriptions are gone and the agent must open them again. +- **A full subscription buffer drops the oldest events.** The read reports a `droppedCount` so + the agent knows it missed some; read more often or narrow the filter. +- **The HTTP transport has no authentication.** See [Run it over HTTP](#run-it-over-http). +- **Deletion is advisory.** NIP-09 asks relays to forget an event; it cannot compel them. Treat + anything published as permanent, which is why `write-policy: confirm` is the default. + +## Checking that a model can still use the tools + +Most tests here ask whether the tools work. One asks whether a model can *use* them, which is a +different question: a tool can be correct and still unusable because its name misleads or its +description omits what the model needs to decide. + +`OllamaAgentIT` runs a real local model against the live tool surface and checks that it picks +the right tool unprompted, tells querying from subscribing, and reads a publish preview as "not +yet published" rather than as success. + +```bash +# Needs Ollama models cached at ~/.ollama/models. Skips cleanly if they are absent. +mvn -pl nostr-java-mcp verify -Dexcluded.it.groups= -Dgroups=model-driven +``` + +It is excluded from the ordinary build because it takes several minutes. Run it when you change +a tool's name, description or schema: those are exactly the changes nothing else can catch. + +## Related + +- [Send and read NIP-17 private direct messages](private-direct-messages.md) +- [Publish and subscribe across many relays](multi-relay-publishing.md) +- [The MCP server's design](../explanation/nostr-java-mcp-spec.md) diff --git a/docs/howto/use-nostr-java-api.md b/docs/howto/use-nostr-java-api.md index ae328d6b..1a1ef42c 100644 --- a/docs/howto/use-nostr-java-api.md +++ b/docs/howto/use-nostr-java-api.md @@ -14,7 +14,7 @@ Add the client module to your project (with the BOM): xyz.tcheeric nostr-java-bom - + X.Y.Z pom import diff --git a/docs/howto/version-uplift-workflow.md b/docs/howto/version-uplift-workflow.md index 3e58ceb0..4543472f 100644 --- a/docs/howto/version-uplift-workflow.md +++ b/docs/howto/version-uplift-workflow.md @@ -41,7 +41,7 @@ scripts/release.sh bump --version 1.0.0 ``` - Without Docker (skips Testcontainers-backed ITs): ```bash - mvn -q -DnoDocker=true clean verify + mvn -q -Pno-docker clean verify ``` If any module fails, address it before proceeding. @@ -69,7 +69,7 @@ scripts/release.sh tag --version 1.0.0 --push - Publish to Central using the configured plugin (root POM): ```bash - mvn -q -DskipTests -DnoDocker=true -P release deploy + mvn -q -DskipTests -Pno-docker,release deploy ``` Notes: - The root POM already configures `central-publishing-maven-plugin` to wait until artifacts are published @@ -117,7 +117,7 @@ scripts/release.sh next-snapshot --version 1.0.1-SNAPSHOT xyz.tcheeric nostr-java-bom - 1.0.0 + X.Y.Z pom import @@ -132,7 +132,7 @@ scripts/release.sh next-snapshot --version 1.0.1-SNAPSHOT ``` Tips -- Use `-DnoDocker=true` only when you cannot run ITs; prefer full verify before releasing +- Use `-Pno-docker` only when you cannot run ITs; prefer a full `mvn verify` before releasing - Keep commit messages conventional (e.g., chore, docs, fix, feat) to generate clean changelogs later - If Central publishing fails, rerun with `-X` and consult plugin docs; do not create partial releases diff --git a/docs/integration-test-bug-analysis.md b/docs/integration-test-bug-analysis.md index 0cf01c54..f88bd433 100644 --- a/docs/integration-test-bug-analysis.md +++ b/docs/integration-test-bug-analysis.md @@ -176,9 +176,9 @@ Alternative relay implementations like `strfry` require higher file descriptor l ## Workaround Options ### 1. Skip Integration Tests in CI -Add `-DnoDocker=true` to Maven commands in CI environments where Docker doesn't support TSC properly: +Add the `no-docker` profile to Maven commands in CI environments where Docker cannot run the relay container: ```bash -mvn test -DnoDocker=true +mvn test -Pno-docker ``` ### 2. Use a Different Host/Docker Configuration diff --git a/docs/reference/nostr-java-api.md b/docs/reference/nostr-java-api.md index b8b6a625..75a9c1d6 100644 --- a/docs/reference/nostr-java-api.md +++ b/docs/reference/nostr-java-api.md @@ -1,6 +1,6 @@ # Nostr Java API Reference -Navigation: [Docs index](../README.md) · [Getting started](../GETTING_STARTED.md) · [API how-to](../howto/use-nostr-java-api.md) · [Streaming subscriptions](../howto/streaming-subscriptions.md) · [Custom events](../howto/custom-events.md) +Navigation: [Docs index](../README.md) · [Getting started](../GETTING_STARTED.md) · [API how-to](../howto/use-nostr-java-api.md) · [Multi-relay publishing](../howto/multi-relay-publishing.md) · [Streaming subscriptions](../howto/streaming-subscriptions.md) · [Custom events](../howto/custom-events.md) This document provides an overview of the public API exposed by the `nostr-java` modules. It lists the major classes, their key method signatures, and shows brief usage examples. @@ -160,8 +160,10 @@ public Filters(Filterable... filterables) // Encode a message String json = new EventMessage(event).encode(); -// Decode a message -BaseMessage msg = BaseMessage.read(json); +// Decode a message. The decoder is generic: name the expected message type when you know it, +// so the result needs no cast. +BaseMessage message = new BaseMessageDecoder<>().decode(json); +EventMessage event = new BaseMessageDecoder().decode(json); ``` --- @@ -250,6 +252,124 @@ Send and subscribe operations are annotated with `@NostrRetryable`: --- +## Client API (`nostr-java-api`) + +The entry point for applications. See the +[multi-relay how-to](../howto/multi-relay-publishing.md) for worked examples. + +### `NostrClient` +An identity, a set of relays, and the operations between them. + +```java +static NostrClient.Builder builder() + +PublishResult publish(GenericEvent event) throws NoRelayAcceptedException +PublishResult publishAs(Identity signer, GenericEvent event) throws NoRelayAcceptedException +PublishResult publishTextNote(String content) throws NoRelayAcceptedException + +RelaySubscription subscribe(List filters, SubscriptionListener listener) + +RecipientDeliveryOutcome sendDirectMessage(PublicKey recipient, String content) +List sendDirectMessage(List recipients, String content) +ChatMessage readDirectMessage(GenericEvent giftWrap) + +Optional findDirectMessageRelays(PublicKey owner) +RelayPool getRelayPool() +Identity getIdentity() +void close() +``` + +Builder: `identity(Identity)`, `relays(String...)`, `relays(List)`, +`relayPool(RelayPool)`, `connectionFactory(RelayConnectionFactory)`, `build()`. + +**Ownership follows construction.** A pool built from relay URIs is closed with the client; a +pool passed to `relayPool(...)` is left to whoever created it, so it may outlive the client. + +### `RecipientDeliveryOutcome` +Whether one participant received a direct message: `recipient()`, `status()` +(`DELIVERED`, `UNREACHABLE`, `REJECTED`), `relays()`, `isDelivered()`, `findReason()`. + +A recipient who published no kind-10050 relay list is `UNREACHABLE`, since NIP-17 treats that as +declining private messages. + +### `RelayListLookup` +Implements `DirectMessageRelayLookup` by querying relays for kind-10050 lists. + +--- + +## Relay Pool (`nostr-java-client`) + +### `RelayPool` +A mutable set of relay connections, with fan-out publishing and fan-in subscriptions. + +```java +RelayPool(List relayUris, RelayConnectionFactory connectionFactory) +RelayPool(List relayUris, RelayConnectionFactory factory, Duration publishTimeout) +// further constructors add reconnectInterval and backlogTimeout + +PublishResult publish(GenericEvent event) throws NoRelayAcceptedException +RelaySubscription subscribe(List filters, SubscriptionListener listener) +RelaySubscription subscribe(List filters, SubscriptionListener listener, int windowSize) + +boolean addRelay(String relayUri) +boolean releaseRelay(String relayUri) +boolean removeRelay(String relayUri) +List retryUnreachableRelays() + +List getRelays() +List getConnectedRelays() +List getUnreachableRelays() +Optional getConnectionState(String relayUri) +void close() +``` + +Defaults: `DEFAULT_PUBLISH_TIMEOUT` 10s, `DEFAULT_RECONNECT_INTERVAL` 30s, +`DEFAULT_BACKLOG_TIMEOUT` 10s. + +### `PublishResult` +What every relay did with one event: `getEventId()`, `getOutcomes()`, `findOutcome(String)`, +`getAcceptingRelays()`, `getFailures()`, `isAccepted()`, `isAcceptedByAllRelays()`. + +### `RelayPublishOutcome` +One relay's verdict: `relayUri()`, `status()` (`ACCEPTED`, `REJECTED`, `TIMED_OUT`, +`UNREACHABLE`), `reason()`, `isAccepted()`, `findReason()`. + +### `NoRelayAcceptedException` +Thrown when no relay stored the event. Carries the full `PublishResult` via +`getPublishResult()`, so the caller can still see what each relay said. + +### `RelaySubscription` +One subscription across many relays: `getSubscriptionId()`, `getSubscribedRelays()`, +`hasAnnouncedEndOfStoredEvents()`, `close()`. + +### `SubscriptionListener` +`onEvent(GenericEvent)`, plus optional `onEndOfStoredEvents()` and +`onRelayFailure(String, Throwable)`. + +Events are delivered in the order the relay sent them, so stored events always arrive before +the end-of-backlog signal. + +### `RelayConnection` / `RelayConnectionFactory` +The seam between relay coordination and transport. `NostrRelayClient` implements +`RelayConnection`; supplying a different factory is how tests substitute scripted relays. + +### Known limitations + +**One request in flight per relay.** `NostrRelayClient` serves a single request at a time, so +the pool serialises operations per relay. Throughput to any one relay is bounded by round-trip +latency. Fan-out across relays is unaffected. Making the client multiplex is separate work; see +[ADR-0004](../decisions/0004-pool-concurrency-and-subscription-lifecycle.md). + +**Transient relays are briefly shared.** A relay added to deliver a direct message is available +to other operations while it remains connected. This is benign, but it means the pool can +publish through a relay the caller never configured. + +**De-duplication is windowed.** Events evicted from the bounded window are treated as new if +they arrive again, so a copy arriving much later than its siblings can be delivered twice. The +default window is sized well beyond realistic cross-relay spread. + +--- + ## Encryption (`nostr-java-identity`) ### `MessageCipher` diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml new file mode 100644 index 00000000..836fa400 --- /dev/null +++ b/nostr-java-api/pom.xml @@ -0,0 +1,79 @@ + + 4.0.0 + + + xyz.tcheeric + nostr-java + 2.3.1 + ../pom.xml + + + nostr-java-api + jar + nostr-java-api + + + + reposilite-releases + https://maven.398ja.xyz/releases + + + reposilite-snapshots + https://maven.398ja.xyz/snapshots + + + + + + + ${project.groupId} + nostr-java-client + + + + + org.projectlombok + lombok + provided + + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.platform + junit-platform-launcher + test + + + xyz.tcheeric + nostr-java-client + ${project.version} + test-jar + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.awaitility + awaitility + test + + + ch.qos.logback + logback-classic + test + + + diff --git a/nostr-java-api/src/main/java/nostr/api/DirectMessagePublisher.java b/nostr-java-api/src/main/java/nostr/api/DirectMessagePublisher.java new file mode 100644 index 00000000..99764487 --- /dev/null +++ b/nostr-java-api/src/main/java/nostr/api/DirectMessagePublisher.java @@ -0,0 +1,127 @@ +package nostr.api; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.client.relay.RelayPool; +import nostr.encryption.DirectMessageRelayLookup; +import nostr.encryption.DirectMessageService; +import nostr.encryption.MessageDelivery; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Sends NIP-17 private direct messages and reads them back. + * + *

{@code nostr-java-identity} can plan a delivery but not perform one: it works out which + * gift wrap belongs to which recipient and which relays each nominated, then stops, because + * sending would give a policy module a transport. This service performs the plan, connecting to + * each recipient's own relays rather than the sender's, since NIP-17 permits delivery only + * there. + * + *

Relays borrowed for a delivery are released afterwards, so a long-running application does + * not accumulate a connection for every person it has ever messaged. + * + * @see NIP-17 + */ +@Slf4j +public class DirectMessagePublisher { + + private final DirectMessageService directMessages; + private final DirectMessageRelayLookup relayLists; + private final RelayPool relayPool; + + /** + * @param directMessages composes and reads NIP-17 messages + * @param relayLists finds where each recipient receives messages + * @param relayPool the connections used to deliver + */ + public DirectMessagePublisher( + @NonNull DirectMessageService directMessages, + @NonNull DirectMessageRelayLookup relayLists, + @NonNull RelayPool relayPool) { + this.directMessages = directMessages; + this.relayLists = relayLists; + this.relayPool = relayPool; + } + + /** + * Send a message to every participant, reporting who received it. + * + *

Never throws for an undelivered recipient: a group message that reaches three of four + * people has partly succeeded, and the caller needs to know which one missed out rather than + * losing the whole result to an exception. + * + * @param message the message to send + * @return one outcome per participant + */ + public List send(@NonNull ChatMessage message) { + List outcomes = new ArrayList<>(); + for (MessageDelivery delivery : directMessages.planDelivery(message, relayLists)) { + outcomes.add(deliver(delivery)); + } + return List.copyOf(outcomes); + } + + /** + * Read an incoming gift wrap back into the message it conceals. + * + * @param giftWrap the received wrap + * @return the message inside + */ + public ChatMessage read(@NonNull GenericEvent giftWrap) { + return directMessages.read(giftWrap); + } + + private RecipientDeliveryOutcome deliver(MessageDelivery delivery) { + String recipient = delivery.recipient().toString(); + if (!delivery.isDeliverable()) { + log.info("Not sending to {}: they publish no direct message relay list", recipient); + return RecipientDeliveryOutcome.unreachable(recipient); + } + List recipientRelays = relayUrisOf(delivery); + recipientRelays.forEach(relayPool::addRelay); + try { + return publishTo(recipient, delivery.giftWrap(), recipientRelays); + } finally { + recipientRelays.forEach(relayPool::releaseRelay); + } + } + + /** + * Publish one recipient's wrap and report only what their own relays did with it. + * + *

The pool may hold other relays, and an acceptance by one of those says nothing about + * whether this recipient can read the message. + */ + private RecipientDeliveryOutcome publishTo( + String recipient, GenericEvent giftWrap, List recipientRelays) { + try { + PublishResult result = relayPool.publish(giftWrap); + List accepted = + result.getAcceptingRelays().stream().filter(recipientRelays::contains).toList(); + return accepted.isEmpty() + ? RecipientDeliveryOutcome.rejected(recipient, describeFailures(result)) + : RecipientDeliveryOutcome.delivered(recipient, accepted); + } catch (NoRelayAcceptedException e) { + return RecipientDeliveryOutcome.rejected(recipient, e.getMessage()); + } + } + + private String describeFailures(PublishResult result) { + return result.getFailures().stream() + .map(outcome -> outcome.relayUri() + ": " + outcome.status()) + .reduce((first, second) -> first + ", " + second) + .orElse("No relay of theirs accepted the message"); + } + + private List relayUrisOf(MessageDelivery delivery) { + return delivery.relays().stream().map(Relay::getUri).distinct().toList(); + } +} diff --git a/nostr-java-api/src/main/java/nostr/api/NostrClient.java b/nostr-java-api/src/main/java/nostr/api/NostrClient.java new file mode 100644 index 00000000..caf93a59 --- /dev/null +++ b/nostr-java-api/src/main/java/nostr/api/NostrClient.java @@ -0,0 +1,317 @@ +package nostr.api; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.client.relay.RelayConnection; +import nostr.client.relay.RelayConnectionFactory; +import nostr.client.relay.RelayPool; +import nostr.client.relay.RelaySubscription; +import nostr.client.relay.SubscriptionListener; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.encryption.DirectMessageService; +import nostr.encryption.Nip17DirectMessageService; +import nostr.event.filter.EventFilter; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +/** + * The entry point for talking to Nostr: an identity, some relays, and the operations between + * them. + * + *

The lower modules are deliberately narrow, so an application assembling them itself writes + * the same connection handling, fan-out, de-duplication and delivery orchestration every time. + * This client owns that work. + * + *

{@code
+ * try (NostrClient nostr = NostrClient.builder()
+ *         .identity(identity)
+ *         .relays("wss://relay.398ja.xyz", "wss://nos.lol")
+ *         .build()) {
+ *
+ *     nostr.publishTextNote("Hello Nostr!");
+ *     nostr.sendDirectMessage(recipient, "hi");
+ * }
+ * }
+ * + *

Nothing here is a replacement for the types below it: events remain {@link GenericEvent}, + * and an application needing finer control can reach the {@link RelayPool} directly. + * + *

Ownership follows construction. A pool this client built from relay URIs + * is closed with it; a pool handed in by the caller is left alone, so it can outlive the client + * in a container. + */ +public final class NostrClient implements AutoCloseable { + + private final Identity identity; + private final RelayPool relayPool; + private final boolean ownsRelayPool; + private final DirectMessageService directMessages; + private final RelayListLookup relayLists; + private final DirectMessagePublisher directMessagePublisher; + + private NostrClient(Builder builder) { + this.identity = builder.identity; + this.relayPool = builder.resolveRelayPool(); + this.ownsRelayPool = builder.relayPool == null; + this.directMessages = new Nip17DirectMessageService(identity); + this.relayLists = new RelayListLookup(relayPool); + this.directMessagePublisher = + new DirectMessagePublisher(directMessages, relayLists, relayPool); + } + + /** + * Start describing a client. + * + * @return a builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Sign an event with this client's identity and publish it to every relay. + * + * @param event the event to sign and publish + * @return what each relay did with it + * @throws NoRelayAcceptedException when not one relay stored the event + */ + public PublishResult publish(@NonNull GenericEvent event) throws NoRelayAcceptedException { + return publishAs(identity, event); + } + + /** + * Sign an event as somebody else and publish it. + * + *

An application acting for several keys, such as a bridge or a bot host, would otherwise + * need one client per key. + * + * @param signer the identity to sign with + * @param event the event to sign and publish + * @return what each relay did with it + * @throws NoRelayAcceptedException when not one relay stored the event + */ + public PublishResult publishAs(@NonNull Identity signer, @NonNull GenericEvent event) + throws NoRelayAcceptedException { + signer.sign(event); + return relayPool.publish(event); + } + + /** + * Publish a text note authored by this client's identity. + * + * @param content the note's text + * @return what each relay did with it + * @throws NoRelayAcceptedException when not one relay stored the note + */ + public PublishResult publishTextNote(@NonNull String content) throws NoRelayAcceptedException { + return publish( + GenericEvent.builder() + .pubKey(identity.getPublicKey()) + .kind(TEXT_NOTE_KIND) + .content(content) + .build()); + } + + /** + * Subscribe across every relay, receiving each matching event once. + * + * @param filters what to subscribe to + * @param listener receives events, the end-of-backlog signal, and per-relay failures + * @return the subscription, which unsubscribes everywhere when closed + */ + public RelaySubscription subscribe( + @NonNull List filters, @NonNull SubscriptionListener listener) { + return relayPool.subscribe(filters, listener); + } + + /** + * Send a private direct message to one recipient. + * + * @param recipient who to send to + * @param content the message text + * @return whether the recipient received it + */ + public RecipientDeliveryOutcome sendDirectMessage( + @NonNull PublicKey recipient, @NonNull String content) { + return sendDirectMessage(List.of(recipient), content).getFirst(); + } + + /** + * Send a private direct message to several recipients. + * + * @param recipients who to send to + * @param content the message text + * @return one outcome per recipient, since a group message can partly succeed + */ + public List sendDirectMessage( + @NonNull List recipients, @NonNull String content) { + return directMessagePublisher.send( + ChatMessage.builder() + .from(identity.getPublicKey()) + .to(recipients) + .content(content) + .build()); + } + + /** + * Read an incoming gift wrap back into the message it conceals. + * + * @param giftWrap the received wrap + * @return the message inside + */ + public ChatMessage readDirectMessage(@NonNull GenericEvent giftWrap) { + return directMessagePublisher.read(giftWrap); + } + + /** + * Find where someone receives private direct messages. + * + * @param owner the key whose relay list is wanted + * @return their relay list, or empty when they published none + */ + public Optional findDirectMessageRelays(@NonNull PublicKey owner) { + return relayLists.findFor(owner); + } + + /** + * The relay pool underneath, for work this client does not cover. + * + * @return the pool + */ + public RelayPool getRelayPool() { + return relayPool; + } + + /** + * The identity this client signs with by default. + * + * @return the identity + */ + public Identity getIdentity() { + return identity; + } + + @Override + public void close() { + if (ownsRelayPool) { + relayPool.close(); + } + } + + private static final int TEXT_NOTE_KIND = 1; + private static final long RELAY_CONNECT_TIMEOUT_MS = 60_000L; + + /** Describes a {@link NostrClient} before building it. */ + public static final class Builder { + + private Identity identity; + private final List relayUris = new ArrayList<>(); + private RelayPool relayPool; + private RelayConnectionFactory connectionFactory = Builder::connectToRelay; + + private Builder() {} + + /** + * The identity events are signed with unless a call overrides it. + * + * @param identity the signing identity + * @return this builder + */ + public Builder identity(@NonNull Identity identity) { + this.identity = identity; + return this; + } + + /** + * The relays to connect to. + * + * @param relayUris the relay WebSocket URIs + * @return this builder + */ + public Builder relays(@NonNull String... relayUris) { + return relays(List.of(relayUris)); + } + + /** + * The relays to connect to. + * + * @param relayUris the relay WebSocket URIs + * @return this builder + */ + public Builder relays(@NonNull List relayUris) { + this.relayUris.addAll(relayUris); + return this; + } + + /** + * Use an existing pool instead of building one. + * + *

The client will not close a pool given this way: whoever created it + * keeps that responsibility, so the pool may outlive the client. + * + * @param relayPool the pool to use + * @return this builder + */ + public Builder relayPool(@NonNull RelayPool relayPool) { + this.relayPool = relayPool; + return this; + } + + /** + * How relay connections are opened, for tests and unusual transports. + * + * @param connectionFactory opens a connection for a relay URI + * @return this builder + */ + public Builder connectionFactory(@NonNull RelayConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + return this; + } + + /** + * Build the client. + * + * @return the client + */ + public NostrClient build() { + Objects.requireNonNull(identity, "identity is required"); + if (relayPool == null && relayUris.isEmpty()) { + throw new IllegalStateException("Either relays or a relay pool is required"); + } + return new NostrClient(this); + } + + private RelayPool resolveRelayPool() { + return relayPool != null ? relayPool : new RelayPool(relayUris, connectionFactory); + } + + /** + * Open a real WebSocket connection to a relay. + * + *

Wrapped rather than referenced directly because the client's constructor reports + * interruption and connection failure separately, while a caller opening a relay only needs + * to know whether it is reachable. + */ + private static RelayConnection connectToRelay(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, RELAY_CONNECT_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while connecting to relay " + relayUri, e); + } catch (ExecutionException e) { + throw new IOException("Could not connect to relay " + relayUri, e.getCause()); + } + } + } +} diff --git a/nostr-java-api/src/main/java/nostr/api/RecipientDeliveryOutcome.java b/nostr-java-api/src/main/java/nostr/api/RecipientDeliveryOutcome.java new file mode 100644 index 00000000..35f75053 --- /dev/null +++ b/nostr-java-api/src/main/java/nostr/api/RecipientDeliveryOutcome.java @@ -0,0 +1,91 @@ +package nostr.api; + +import lombok.NonNull; + +import java.util.List; +import java.util.Optional; + +/** + * Whether one participant's copy of a direct message reached them. + * + *

A group message succeeds for some recipients and fails for others, so delivery has no + * single verdict. Reporting per recipient lets an application tell a user precisely who did not + * receive their message, rather than only that something went wrong. + * + * @param recipient the participant this outcome concerns + * @param status whether their copy was delivered + * @param relays the relays their copy was accepted by + * @param reason why delivery failed, when it did + */ +public record RecipientDeliveryOutcome( + String recipient, Status status, List relays, String reason) { + + /** How a participant's copy fared. */ + public enum Status { + /** At least one of the recipient's relays stored their copy. */ + DELIVERED, + /** The recipient published no relay list, so NIP-17 forbids sending to them. */ + UNREACHABLE, + /** The recipient's relays were asked but none stored their copy. */ + REJECTED + } + + public RecipientDeliveryOutcome { + relays = relays == null ? List.of() : List.copyOf(relays); + reason = reason == null ? "" : reason; + } + + /** + * Record a copy that reached its recipient. + * + * @param recipient the participant reached + * @param relays the relays that stored their copy + * @return the delivered outcome + */ + public static RecipientDeliveryOutcome delivered( + @NonNull String recipient, @NonNull List relays) { + return new RecipientDeliveryOutcome(recipient, Status.DELIVERED, relays, ""); + } + + /** + * Record a participant who publishes no relay list. + * + *

NIP-17 treats this as declining private messages, so nothing is sent for them at all. + * + * @param recipient the unreachable participant + * @return the unreachable outcome + */ + public static RecipientDeliveryOutcome unreachable(@NonNull String recipient) { + return new RecipientDeliveryOutcome( + recipient, Status.UNREACHABLE, List.of(), "Publishes no direct message relay list"); + } + + /** + * Record a copy that every one of the recipient's relays refused. + * + * @param recipient the participant not reached + * @param reason what the relays reported + * @return the rejected outcome + */ + public static RecipientDeliveryOutcome rejected(@NonNull String recipient, String reason) { + return new RecipientDeliveryOutcome(recipient, Status.REJECTED, List.of(), reason); + } + + /** + * Whether this participant received the message. + * + * @return {@code true} when at least one of their relays stored it + */ + public boolean isDelivered() { + return status == Status.DELIVERED; + } + + /** + * Why delivery failed, when it did. + * + * @return the reason, empty for a delivered copy + */ + public Optional findReason() { + return reason.isBlank() ? Optional.empty() : Optional.of(reason); + } +} diff --git a/nostr-java-api/src/main/java/nostr/api/RelayListLookup.java b/nostr-java-api/src/main/java/nostr/api/RelayListLookup.java new file mode 100644 index 00000000..6073de68 --- /dev/null +++ b/nostr-java-api/src/main/java/nostr/api/RelayListLookup.java @@ -0,0 +1,146 @@ +package nostr.api; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.base.PublicKey; +import nostr.client.relay.RelayPool; +import nostr.client.relay.RelaySubscription; +import nostr.client.relay.SubscriptionListener; +import nostr.encryption.DirectMessageRelayLookup; +import nostr.event.filter.EventFilter; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Finds where someone receives private direct messages, by asking relays. + * + *

NIP-17 delivers a message only to the relays its recipient nominated in a kind-10050 list, + * so sending one requires resolving that list first. {@code nostr-java-identity} declares the + * need as {@link DirectMessageRelayLookup} but cannot satisfy it, because answering means + * querying relays and that module is deliberately transport-free. This implementation supplies + * the answer from a relay pool, keeping the dependency pointing from transport towards policy. + * + * @see NIP-17 + */ +@Slf4j +public class RelayListLookup implements DirectMessageRelayLookup { + + /** How long to wait for relays to answer before concluding no list was published. */ + public static final Duration DEFAULT_LOOKUP_TIMEOUT = Duration.ofSeconds(5); + + private static final int DIRECT_MESSAGE_RELAY_LIST_KIND = 10050; + + private final RelayPool relayPool; + private final Duration lookupTimeout; + + /** + * @param relayPool the relays to ask + */ + public RelayListLookup(@NonNull RelayPool relayPool) { + this(relayPool, DEFAULT_LOOKUP_TIMEOUT); + } + + /** + * @param relayPool the relays to ask + * @param lookupTimeout how long to wait before concluding no list was published + */ + public RelayListLookup(@NonNull RelayPool relayPool, @NonNull Duration lookupTimeout) { + this.relayPool = relayPool; + this.lookupTimeout = lookupTimeout; + } + + /** + * Find the relay list this key published for receiving direct messages. + * + *

An empty result means no list was found, which NIP-17 treats as declining private + * messages rather than as an error. A relay that never answers therefore looks the same as one + * confirming no list exists, which is why the wait is bounded. + * + * @param owner the key whose relay list is wanted + * @return their relay list, or empty when they published none + */ + @Override + public Optional findFor(@NonNull PublicKey owner) { + AtomicReference mostRecentList = new AtomicReference<>(); + CountDownLatch backlogDrained = new CountDownLatch(1); + + try (RelaySubscription subscription = + relayPool.subscribe( + List.of(relayListFilterFor(owner)), + collectMostRecent(mostRecentList, backlogDrained))) { + + awaitBacklog(backlogDrained); + } + return Optional.ofNullable(mostRecentList.get()).map(DirectMessageRelayList::from); + } + + private EventFilter relayListFilterFor(PublicKey owner) { + return EventFilter.builder() + .author(owner.toString()) + .kind(DIRECT_MESSAGE_RELAY_LIST_KIND) + .build(); + } + + /** + * Keep the newest list seen, since relays can hold different revisions. + * + *

A replaceable event's latest version is the one that counts, and an older copy lingering + * on one relay must not override a newer one held elsewhere. + */ + private SubscriptionListener collectMostRecent( + AtomicReference mostRecentList, CountDownLatch backlogDrained) { + return new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + mostRecentList.accumulateAndGet(event, RelayListLookup::newerOf); + } + + @Override + public void onEndOfStoredEvents() { + backlogDrained.countDown(); + } + }; + } + + /** + * Choose the newer of the list already held and one just received. + * + *

Argument order follows {@code AtomicReference.accumulateAndGet}, which passes the current + * value first and the new one second; either may be absent on the first sighting. + */ + private static GenericEvent newerOf(GenericEvent held, GenericEvent received) { + if (held == null) { + return received; + } + if (received == null) { + return held; + } + return publishedAt(received) >= publishedAt(held) ? received : held; + } + + /** + * When an event claims to have been created, treating an absent timestamp as oldest. + * + *

A relay can serve an event without one, and an undated copy must never displace a dated + * one that is known to be current. + */ + private static long publishedAt(GenericEvent event) { + return event.getCreatedAt() == null ? Long.MIN_VALUE : event.getCreatedAt(); + } + + private void awaitBacklog(CountDownLatch backlogDrained) { + try { + backlogDrained.await(lookupTimeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while looking up a direct message relay list"); + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/DirectMessagePublisherTest.java b/nostr-java-api/src/test/java/nostr/api/DirectMessagePublisherTest.java new file mode 100644 index 00000000..2aa4c8b4 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/DirectMessagePublisherTest.java @@ -0,0 +1,172 @@ +package nostr.api; + +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.encryption.DirectMessageRelayLookup; +import nostr.encryption.Nip17DirectMessageService; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.message.EventMessage; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies a NIP-17 message is delivered to each recipient's own relays, and that the sender + * learns exactly who received it. + */ +class DirectMessagePublisherTest { + + private static final String SENDER_RELAY = "wss://relay.sender"; + private static final String ALICE_RELAY = "wss://relay.alice"; + private static final String BOB_RELAY = "wss://relay.bob"; + + private final Map relays = new ConcurrentHashMap<>(); + private final Identity sender = Identity.generateRandomIdentity(); + + // Verifies a message is published to the recipient's own nominated relay, since NIP-17 permits + // delivery only there and not to the sender's relays. + @Test + void aMessageIsDeliveredToTheRecipientsOwnRelays() throws Exception { + Identity alice = Identity.generateRandomIdentity(); + + try (RelayPool pool = poolOf(SENDER_RELAY)) { + List outcomes = + publisherFor(pool, relayListsOf(Map.of(alice.getPublicKey(), ALICE_RELAY))) + .send(messageTo(alice.getPublicKey())); + + RecipientDeliveryOutcome toAlice = outcomeFor(outcomes, alice.getPublicKey()); + assertTrue(toAlice.isDelivered()); + assertEquals(List.of(ALICE_RELAY), toAlice.relays()); + assertEquals(1, relays.get(ALICE_RELAY).getSentMessages().size()); + } + } + + // Verifies a recipient who published no relay list is reported unreachable rather than + // silently skipped, so a sender can tell the user their message did not arrive. + @Test + void aRecipientWithNoRelayListIsReportedUnreachable() throws Exception { + Identity silent = Identity.generateRandomIdentity(); + + try (RelayPool pool = poolOf(SENDER_RELAY)) { + List outcomes = + publisherFor(pool, owner -> Optional.empty()).send(messageTo(silent.getPublicKey())); + + RecipientDeliveryOutcome toSilent = outcomeFor(outcomes, silent.getPublicKey()); + assertFalse(toSilent.isDelivered()); + assertEquals(RecipientDeliveryOutcome.Status.UNREACHABLE, toSilent.status()); + assertTrue(toSilent.findReason().isPresent()); + } + } + + // Verifies a group message reports each recipient separately, so partial delivery is visible + // rather than collapsed into one verdict. + @Test + void aGroupMessageReportsEachRecipientSeparately() throws Exception { + Identity alice = Identity.generateRandomIdentity(); + Identity bob = Identity.generateRandomIdentity(); + Identity silent = Identity.generateRandomIdentity(); + + try (RelayPool pool = poolOf(SENDER_RELAY)) { + List outcomes = + publisherFor( + pool, + relayListsOf( + Map.of(alice.getPublicKey(), ALICE_RELAY, bob.getPublicKey(), BOB_RELAY))) + .send( + messageTo(alice.getPublicKey(), bob.getPublicKey(), silent.getPublicKey())); + + assertTrue(outcomeFor(outcomes, alice.getPublicKey()).isDelivered()); + assertTrue(outcomeFor(outcomes, bob.getPublicKey()).isDelivered()); + assertEquals( + RecipientDeliveryOutcome.Status.UNREACHABLE, + outcomeFor(outcomes, silent.getPublicKey()).status()); + } + } + + // Verifies relays borrowed to reach a recipient are released afterwards, so a long-running + // application does not accumulate a connection per person it has messaged. + @Test + void relaysBorrowedForADeliveryAreReleasedAfterwards() throws Exception { + Identity alice = Identity.generateRandomIdentity(); + + try (RelayPool pool = poolOf(SENDER_RELAY)) { + publisherFor(pool, relayListsOf(Map.of(alice.getPublicKey(), ALICE_RELAY))) + .send(messageTo(alice.getPublicKey())); + + assertEquals(List.of(SENDER_RELAY), pool.getRelays()); + } + } + + // Verifies what was sent can be read back into the original message, so send and receive are + // symmetrical through the same service. + @Test + void aSentMessageCanBeReadBack() throws Exception { + Identity alice = Identity.generateRandomIdentity(); + + try (RelayPool pool = poolOf(SENDER_RELAY)) { + DirectMessagePublisher publisher = + publisherFor(pool, relayListsOf(Map.of(alice.getPublicKey(), ALICE_RELAY))); + publisher.send(messageTo(alice.getPublicKey())); + + EventMessage wrap = (EventMessage) relays.get(ALICE_RELAY).getSentMessages().getFirst(); + ChatMessage read = + new DirectMessagePublisher( + new Nip17DirectMessageService(alice), owner -> Optional.empty(), pool) + .read(wrap.getEvent()); + + assertEquals("dinner at eight", read.getContent()); + assertEquals(sender.getPublicKey(), read.getSender()); + } + } + + /** + * Find one participant's outcome. + * + *

Every conversation includes the sender, because NIP-17 requires a copy addressed to them + * as well, so outcomes are looked up by key rather than by position. + */ + private RecipientDeliveryOutcome outcomeFor( + List outcomes, PublicKey participant) { + return outcomes.stream() + .filter(outcome -> outcome.recipient().equals(participant.toString())) + .findFirst() + .orElseThrow(() -> new AssertionError("No outcome reported for " + participant)); + } + + private DirectMessagePublisher publisherFor(RelayPool pool, DirectMessageRelayLookup lookup) { + return new DirectMessagePublisher(new Nip17DirectMessageService(sender), lookup, pool); + } + + private DirectMessageRelayLookup relayListsOf(Map relayByOwner) { + return owner -> + Optional.ofNullable(relayByOwner.get(owner)) + .map( + relayUri -> + new DirectMessageRelayList( + owner, List.of(new Relay(relayUri)), System.currentTimeMillis() / 1000)); + } + + private ChatMessage messageTo(PublicKey... recipients) { + return ChatMessage.builder() + .from(sender.getPublicKey()) + .to(List.of(recipients)) + .content("dinner at eight") + .build(); + } + + private RelayPool poolOf(String... relayUris) { + return new RelayPool( + List.of(relayUris), relayUri -> relays.computeIfAbsent(relayUri, FakeRelay::accepting)); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/NostrClientTest.java b/nostr-java-api/src/test/java/nostr/api/NostrClientTest.java new file mode 100644 index 00000000..a3111a8b --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/NostrClientTest.java @@ -0,0 +1,161 @@ +package nostr.api; + +import nostr.base.PublicKey; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.ConnectionState; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.event.message.EventMessage; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies the facade signs, publishes, and cleans up according to who owns what. */ +class NostrClientTest { + + private static final String FIRST_RELAY = "wss://relay.one"; + private static final String SECOND_RELAY = "wss://relay.two"; + + private final Map relays = new ConcurrentHashMap<>(); + private final Identity identity = Identity.generateRandomIdentity(); + + // Verifies a text note is signed by the configured identity and published to every relay, + // collapsing build, sign and publish into one call. + @Test + void aTextNoteIsSignedAndPublishedToEveryRelay() throws Exception { + try (NostrClient nostr = clientWithRelays(FIRST_RELAY, SECOND_RELAY)) { + PublishResult result = nostr.publishTextNote("Hello Nostr!"); + + assertEquals(List.of(FIRST_RELAY, SECOND_RELAY), result.getAcceptingRelays()); + GenericEvent published = firstEventSentTo(FIRST_RELAY); + assertEquals(identity.getPublicKey(), published.getPubKey()); + assertEquals("Hello Nostr!", published.getContent()); + assertNotNull(published.getSignature(), "the note was published unsigned"); + } + } + + // Verifies an event can be signed by another identity, so one client can act for several keys + // rather than needing one client per key. + @Test + void anEventCanBeSignedByAnotherIdentity() throws Exception { + Identity otherAccount = Identity.generateRandomIdentity(); + + try (NostrClient nostr = clientWithRelays(FIRST_RELAY)) { + nostr.publishAs( + otherAccount, + GenericEvent.builder() + .pubKey(otherAccount.getPublicKey()) + .kind(1) + .content("posted for another account") + .build()); + + assertEquals(otherAccount.getPublicKey(), firstEventSentTo(FIRST_RELAY).getPubKey()); + assertNotEquals(identity.getPublicKey(), firstEventSentTo(FIRST_RELAY).getPubKey()); + } + } + + // Verifies publishing throws when no relay accepted, so an event that reached nobody cannot be + // mistaken for a published one. + @Test + void publishingThrowsWhenNoRelayAccepted() throws Exception { + relays.put(FIRST_RELAY, FakeRelay.rejecting(FIRST_RELAY, "blocked: pubkey banned")); + + try (NostrClient nostr = clientWithRelays(FIRST_RELAY)) { + assertThrows(NoRelayAcceptedException.class, () -> nostr.publishTextNote("rejected")); + } + } + + // Verifies a subscription opened through the facade covers every relay in the pool. + @Test + void subscribingCoversEveryRelay() throws Exception { + try (NostrClient nostr = clientWithRelays(FIRST_RELAY, SECOND_RELAY); + var subscription = + nostr.subscribe(List.of(EventFilter.builder().kind(1).build()), event -> {})) { + + assertEquals( + relays.keySet(), subscription.getSubscribedRelays()); + } + } + + // Verifies a pool the client built is closed with it, so try-with-resources releases the + // connections the client opened. + @Test + void aPoolTheClientBuiltIsClosedWithIt() throws Exception { + try (NostrClient nostr = clientWithRelays(FIRST_RELAY)) { + assertEquals(ConnectionState.CONNECTED, relays.get(FIRST_RELAY).getConnectionState()); + } + + assertEquals(ConnectionState.CLOSED, relays.get(FIRST_RELAY).getConnectionState()); + } + + // Verifies a pool supplied by the caller outlives the client, so a container can own a pool + // shared by several short-lived clients. + @Test + void aSuppliedPoolOutlivesTheClient() throws Exception { + RelayPool callerOwnedPool = + new RelayPool( + List.of(FIRST_RELAY), + relayUri -> relays.computeIfAbsent(relayUri, FakeRelay::accepting)); + + try (NostrClient nostr = + NostrClient.builder().identity(identity).relayPool(callerOwnedPool).build()) { + nostr.publishTextNote("still mine"); + } + + assertEquals( + ConnectionState.CONNECTED, + relays.get(FIRST_RELAY).getConnectionState(), + "the client closed a pool it did not create"); + callerOwnedPool.close(); + assertEquals(ConnectionState.CLOSED, relays.get(FIRST_RELAY).getConnectionState()); + } + + // Verifies a client cannot be built without an identity, since every operation signs with one. + @Test + void aClientCannotBeBuiltWithoutAnIdentity() { + assertThrows( + NullPointerException.class, () -> NostrClient.builder().relays(FIRST_RELAY).build()); + } + + // Verifies a client cannot be built without somewhere to send, which would otherwise fail only + // at the first publish. + @Test + void aClientCannotBeBuiltWithoutRelays() { + assertThrows( + IllegalStateException.class, () -> NostrClient.builder().identity(identity).build()); + } + + // Verifies the underlying pool is reachable, so an application needing finer control is not + // walled off by the facade. + @Test + void theUnderlyingPoolIsReachable() throws Exception { + try (NostrClient nostr = clientWithRelays(FIRST_RELAY)) { + assertTrue(nostr.getRelayPool().getRelays().contains(FIRST_RELAY)); + assertEquals(identity, nostr.getIdentity()); + } + } + + private GenericEvent firstEventSentTo(String relayUri) { + return ((EventMessage) relays.get(relayUri).getSentMessages().getFirst()).getEvent(); + } + + private NostrClient clientWithRelays(String... relayUris) { + return NostrClient.builder() + .identity(identity) + .relays(relayUris) + .connectionFactory(relayUri -> relays.computeIfAbsent(relayUri, FakeRelay::accepting)) + .build(); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/RelayListLookupTest.java b/nostr-java-api/src/test/java/nostr/api/RelayListLookupTest.java new file mode 100644 index 00000000..9d6fa2fe --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/RelayListLookupTest.java @@ -0,0 +1,98 @@ +package nostr.api; + +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies a recipient's direct message relays are resolved from what relays actually hold. */ +class RelayListLookupTest { + + private static final String FIRST_RELAY = "wss://relay.one"; + private static final String SECOND_RELAY = "wss://relay.two"; + + private final Map relays = new ConcurrentHashMap<>(); + + // Verifies a published kind-10050 list is found and its relays returned, which is what makes + // NIP-17 delivery possible at all. + @Test + void aPublishedRelayListIsFound() throws Exception { + Identity recipient = Identity.generateRandomIdentity(); + GenericEvent published = relayListOf(recipient, "wss://inbox.one", "wss://inbox.two"); + + try (RelayPool pool = poolOf(FIRST_RELAY, SECOND_RELAY)) { + RelayListLookup lookup = new RelayListLookup(pool); + answerLookupsWith(published); + + Optional found = lookup.findFor(recipient.getPublicKey()); + + assertTrue(found.isPresent(), "the published relay list was not found"); + assertEquals( + List.of("wss://inbox.one", "wss://inbox.two"), + found.orElseThrow().getRelays().stream().map(Relay::getUri).toList()); + } + } + + // Verifies someone who published no list yields an empty result rather than an error, since + // NIP-17 treats that as declining private messages. + @Test + void someoneWithNoPublishedListYieldsAnEmptyResult() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY)) { + RelayListLookup lookup = new RelayListLookup(pool); + relays.values().forEach(relay -> relay.emitEndOfStoredEventsForNextSubscription()); + + Optional found = + lookup.findFor(Identity.generateRandomIdentity().getPublicKey()); + + assertTrue(found.isEmpty()); + } + } + + // Verifies a list held by only one relay is still found, so a recipient is not treated as + // unreachable because some relays lack their list. + @Test + void aListHeldByOnlyOneRelayIsStillFound() throws Exception { + Identity recipient = Identity.generateRandomIdentity(); + GenericEvent published = relayListOf(recipient, "wss://inbox.one"); + + try (RelayPool pool = poolOf(FIRST_RELAY, SECOND_RELAY)) { + RelayListLookup lookup = new RelayListLookup(pool); + relays.get(FIRST_RELAY).answerNextSubscriptionWith(published); + relays.get(SECOND_RELAY).emitEndOfStoredEventsForNextSubscription(); + + assertTrue(lookup.findFor(recipient.getPublicKey()).isPresent()); + } + } + + private void answerLookupsWith(GenericEvent relayList) { + relays.values().forEach(relay -> relay.answerNextSubscriptionWith(relayList)); + } + + private GenericEvent relayListOf(Identity owner, String... relayUris) { + DirectMessageRelayList relayList = + new DirectMessageRelayList( + owner.getPublicKey(), + List.of(relayUris).stream().map(Relay::new).toList(), + System.currentTimeMillis() / 1000); + GenericEvent event = relayList.toEvent(); + owner.sign(event); + return event; + } + + private RelayPool poolOf(String... relayUris) { + return new RelayPool( + List.of(relayUris), relayUri -> relays.computeIfAbsent(relayUri, FakeRelay::accepting)); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/docs/DocumentationAccuracyTest.java b/nostr-java-api/src/test/java/nostr/api/docs/DocumentationAccuracyTest.java new file mode 100644 index 00000000..c7096cdd --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/docs/DocumentationAccuracyTest.java @@ -0,0 +1,312 @@ +package nostr.api.docs; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Holds the documentation to the same standard as the code it describes. + * + *

Documentation rots differently from code: nothing fails when a method is renamed out from + * under a guide, so the guide keeps confidently teaching an API that no longer exists. A reader + * following it does not conclude the docs are stale, they conclude the library is broken. These + * checks turn that silent decay into a build failure. + * + *

Deliberately structural rather than semantic. It cannot tell whether a guide explains + * something well, only whether the types and methods it names are real, its links resolve, and + * its stated version matches the build. Those are the failures a reader hits first. + */ +class DocumentationAccuracyTest { + + private static final Path DOCS = Path.of("../docs"); + private static final Path REPOSITORY_ROOT = Path.of(".."); + + /** + * Guides that teach the API, as opposed to explaining a design or proposing a change. + * + *

Only these are held to "every symbol must exist". A proposal describes code that + * deliberately does not exist yet, and an architecture note may name a type from a + * dependency, so applying the rule there would force writers to stop discussing anything + * hypothetical. + */ + private static final List INSTRUCTIONAL_DIRECTORIES = List.of("howto", "reference"); + + private static final Pattern JAVA_BLOCK = Pattern.compile("```java\\n(.*?)```", Pattern.DOTALL); + /** + * A call on something named like a type: at least one lowercase letter after the first. + * + *

SCREAMING_CASE is excluded because a sample's constants, such as a {@code SENDER} + * identity declared earlier in a guide, are values rather than types and have no source file + * to check against. + */ + private static final Pattern TYPE_USE = + Pattern.compile("\\b([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)\\.([a-z][A-Za-z0-9]*)\\s*\\("); + private static final Pattern MARKDOWN_LINK = Pattern.compile("\\]\\(([^)]+)\\)"); + + /** + * Types that appear in samples but are not this project's to define. + * + *

The JDK and third-party libraries are legitimately referenced by a guide, and pinning + * their methods here would test the JDK rather than this documentation. + */ + private static final Set NOT_OURS_TO_DEFINE = + Set.of( + "List", "Map", "Set", "Collections", "Optional", "Stream", "Duration", "Instant", + "System", "Math", "Thread", "String", "Objects", "Arrays", "HexFormat", "HEX", + "CompletableFuture", "Executors", "TimeUnit", "UUID", "MDC", "Counter", "Timer", + "Files", "Path", "Pattern", "LocalDate", "ChronoUnit", "Logger", "LoggerFactory", + "ObjectMapper", "JsonNode", "Assertions", "Mockito"); + + // Verifies every type an instructional guide tells a reader to call actually exists. A guide + // naming a class that was renamed or removed teaches an API the reader cannot use, and nothing + // else in the build notices. + @Test + void everyTypeTaughtByAGuideExists() { + Map missing = new LinkedHashMap<>(); + + for (Path guide : instructionalGuides()) { + for (String type : typesCalledIn(read(guide))) { + if (!NOT_OURS_TO_DEFINE.contains(type) && sourceFileFor(type).isEmpty()) { + missing.put(type, guide.toString()); + } + } + } + + assertEquals(Map.of(), missing, "guides name types that do not exist in the source"); + } + + // Verifies every method an instructional guide calls exists on the type it is called on. This + // is the failure that bit us: the reference taught BaseMessage.read(json), which had never + // existed, while the real decoder was a different class entirely. + @Test + void everyMethodTaughtByAGuideExists() { + Map missing = new LinkedHashMap<>(); + + for (Path guide : instructionalGuides()) { + for (Map.Entry call : methodCallsIn(read(guide)).entrySet()) { + String type = call.getKey().split("\\.")[0]; + if (NOT_OURS_TO_DEFINE.contains(type)) { + continue; + } + sourceFileFor(type) + .ifPresent( + source -> { + if (!declaresMethod(read(source), call.getValue())) { + missing.put(call.getKey(), guide.toString()); + } + }); + } + } + + assertEquals(Map.of(), missing, "guides call methods that do not exist on those types"); + } + + // Verifies every relative link between documents resolves. A broken link in a documentation + // set is worse than a missing page, because it implies the answer exists and was mislaid. + @Test + void everyInternalLinkResolves() { + List broken = new ArrayList<>(); + + for (Path document : allDocuments()) { + Matcher links = MARKDOWN_LINK.matcher(read(document)); + while (links.find()) { + String target = links.group(1).split("#")[0]; + if (target.isEmpty() || target.startsWith("http") || target.startsWith("mailto:")) { + continue; + } + Path resolved = document.getParent().resolve(target).normalize(); + if (!Files.exists(resolved)) { + broken.add(document + " -> " + target); + } + } + } + + assertEquals(List.of(), broken, "these documentation links point at nothing"); + } + + // Verifies the version in an install snippet is the version being built. A reader who copies a + // stale coordinate gets an older library and none of what the guide then describes. + // + // Only snippets a reader would copy to install the library are checked. A migration guide + // naming the version being upgraded from, a BOM range, or a workflow illustrating a bump are + // all correct while differing from the current version, so a rule that flagged every literal + // would force those documents to lie. + @Test + void installSnippetsNameTheVersionBeingBuilt() { + String version = projectVersion(); + List stale = new ArrayList<>(); + + for (Path document : instructionalGuides()) { + Matcher declared = + Pattern.compile("nostr-java-[a-z]+\\s*\\n\\s*([^<]+)") + .matcher(read(document)); + while (declared.find()) { + String declaredVersion = declared.group(1); + boolean isIllustrative = declaredVersion.contains("+") || declaredVersion.contains("X"); + if (!isIllustrative && !declaredVersion.equals(version)) { + stale.add(document + " says " + declaredVersion + ", project is " + version); + } + } + } + + assertEquals(List.of(), stale, "install snippets name a version other than the one built"); + } + + // Verifies every documentation file is reachable from the index, since a page nobody links to + // is a page nobody reads, however well written it is. + @Test + void everyGuideIsReachableFromTheIndex() { + String index = read(DOCS.resolve("README.md")); + List orphaned = new ArrayList<>(); + + for (Path document : allDocuments()) { + Path relative = DOCS.relativize(document); + if (relative.toString().equals("README.md") || relative.startsWith("decisions")) { + continue; + } + if (!index.contains(relative.toString())) { + orphaned.add(relative.toString()); + } + } + + assertEquals(List.of(), orphaned, "these documents are not linked from docs/README.md"); + } + + // Verifies the entry-point examples name every type they use. A newcomer copies these first, + // and a sample missing an import or naming a type that moved sends them to the issue tracker + // before they have published anything. + // + // This checks the symbols resolve, not that the block compiles: a snippet is a fragment, and + // wrapping fragments in a synthetic class tests the wrapper as much as the documentation. + // The full compile is done deliberately when these samples change. + @Test + void entryPointExamplesNameOnlyRealTypes() { + List entryPoints = + List.of( + REPOSITORY_ROOT.resolve("README.md"), + DOCS.resolve("GETTING_STARTED.md")); + Map missing = new LinkedHashMap<>(); + + for (Path entryPoint : entryPoints) { + for (Map.Entry call : methodCallsIn(read(entryPoint)).entrySet()) { + String type = call.getKey().split("\\.")[0]; + if (NOT_OURS_TO_DEFINE.contains(type)) { + continue; + } + if (sourceFileFor(type).isEmpty()) { + missing.put(call.getKey(), entryPoint.toString()); + continue; + } + sourceFileFor(type) + .ifPresent( + source -> { + if (!declaresMethod(read(source), call.getValue())) { + missing.put(call.getKey(), entryPoint.toString()); + } + }); + } + } + + assertEquals(Map.of(), missing, "the entry-point examples use types or methods that do not exist"); + } + + private List instructionalGuides() { + return allDocuments().stream() + .filter( + document -> + INSTRUCTIONAL_DIRECTORIES.stream() + .anyMatch(directory -> document.toString().contains("/" + directory + "/"))) + .toList(); + } + + private List allDocuments() { + try (Stream walk = Files.walk(DOCS)) { + return walk.filter(path -> path.toString().endsWith(".md")).sorted().toList(); + } catch (IOException e) { + throw new UncheckedIOException("Could not walk the documentation", e); + } + } + + private Set typesCalledIn(String markdown) { + Set types = new LinkedHashSet<>(); + methodCallsIn(markdown).keySet().forEach(call -> types.add(call.split("\\.")[0])); + return types; + } + + /** Every {@code Type.method(} appearing in a Java block, mapped to its method name. */ + private Map methodCallsIn(String markdown) { + Map calls = new LinkedHashMap<>(); + Matcher blocks = JAVA_BLOCK.matcher(markdown); + while (blocks.find()) { + Matcher uses = TYPE_USE.matcher(blocks.group(1)); + while (uses.find()) { + calls.put(uses.group(1) + "." + uses.group(2), uses.group(2)); + } + } + return calls; + } + + /** + * Finds a type's source file, if this project defines it. + * + *

An absent result means the name belongs to something else, such as a local variable a + * sample happened to capitalise, so callers treat it as "not ours" rather than as a failure. + */ + private java.util.Optional sourceFileFor(String type) { + try (Stream walk = Files.walk(REPOSITORY_ROOT)) { + return walk.filter(path -> path.toString().endsWith("/" + type + ".java")) + .filter(path -> path.toString().contains("/src/main/java/")) + .filter(path -> !path.toString().contains("/target/")) + .findFirst(); + } catch (IOException e) { + throw new UncheckedIOException("Could not search for " + type, e); + } + } + + /** + * Whether a source file declares a method by that name. + * + *

Name only, not signature: a guide may legitimately call an overload, and matching + * arguments would reject correct documentation for the sake of precision nobody needs here. + * Builder methods generated by Lombok are accepted through the field they come from. + */ + private boolean declaresMethod(String source, String method) { + if (Pattern.compile("\\b" + Pattern.quote(method) + "\\s*\\(").matcher(source).find()) { + return true; + } + boolean isLombokBuilder = source.contains("@Builder") || source.contains("@Data"); + return isLombokBuilder + && Pattern.compile("\\b" + Pattern.quote(method) + "\\b").matcher(source).find(); + } + + private String projectVersion() { + Matcher version = + Pattern.compile("nostr-java\\s*\\n\\s*([^<]+)") + .matcher(read(REPOSITORY_ROOT.resolve("pom.xml"))); + assertTrue(version.find(), "could not read the project version from the root pom"); + return version.group(1); + } + + private String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/McpSpecAssumptionsIT.java b/nostr-java-api/src/test/java/nostr/api/integration/McpSpecAssumptionsIT.java new file mode 100644 index 00000000..4f211c58 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/McpSpecAssumptionsIT.java @@ -0,0 +1,411 @@ +package nostr.api.integration; + +import nostr.api.NostrClient; +import nostr.api.RecipientDeliveryOutcome; +import nostr.base.PublicKey; +import nostr.client.relay.RelayConnection; +import nostr.client.relay.RelayConnectionFactory; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.ConnectionState; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.event.BaseMessage; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.client.relay.RelaySubscription; +import nostr.client.relay.SubscriptionListener; +import nostr.event.filter.EventFilter; +import nostr.base.Relay; +import nostr.event.impl.Contact; +import nostr.event.impl.ContactList; +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import nostr.event.tag.GenericTag; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.io.IOException; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the SDK behaviours the MCP module specification depends on. + * + *

{@code docs/explanation/nostr-java-mcp-spec.md} makes design decisions that only hold if + * this SDK behaves in particular ways: that a publish reports per relay, that subscribing is + * asynchronous, that a direct message to one person yields two outcomes. A specification is + * not executable, so those assumptions would otherwise rot silently until someone implemented + * against them and found out. + * + *

Each test names the section it protects. A failure here means the SDK moved and that + * section needs rewriting, not that the SDK is wrong. + */ +@Testcontainers +class McpSpecAssumptionsIT { + + private static final DockerImageName RELAY_IMAGE = + DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13"); + private static final int RELAY_PORT = 8080; + private static final int RELAY_STARTUP_ATTEMPTS = 5; + private static final int TEXT_NOTE_KIND = 1; + private static final int DIRECT_MESSAGE_RELAY_LIST_KIND = 10050; + private static final int CONTACT_LIST_KIND = 3; + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(RELAY_IMAGE) + .withExposedPorts(RELAY_PORT) + .withStartupAttempts(RELAY_STARTUP_ATTEMPTS) + .waitingFor( + new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies a publish reports what each relay did, which §9 maps onto a successful MCP tool + // result carrying a per-relay list. + @Test + void publishingReportsPerRelayOutcomes() throws Exception { + try (NostrClient nostr = clientFor(Identity.generateRandomIdentity())) { + PublishResult result = nostr.publishTextNote("per-relay outcomes " + System.nanoTime()); + + assertEquals(List.of(relayUri()), result.getAcceptingRelays()); + assertTrue(result.getFailures().isEmpty()); + } + } + + // Verifies an event that reached no relay throws and carries the result, which §9 maps onto + // RELAY_REJECTED with each relay's reason rendered for the agent. + @Test + void totalFailureThrowsAndCarriesTheResult() throws Exception { + try (NostrClient nostr = + NostrClient.builder() + .identity(Identity.generateRandomIdentity()) + .relays("ws://localhost:1") + .build()) { + + NoRelayAcceptedException thrown = + assertThrows(NoRelayAcceptedException.class, () -> nostr.publishTextNote("nowhere")); + + assertFalse(thrown.getPublishResult().getFailures().isEmpty()); + } + } + + // Verifies subscribing returns before any stored event or the end-of-backlog signal arrives. + // §6.1 depends on this: a tool that blocked until EOSE would stall on an unresponsive relay. + @Test + void subscribingReturnsBeforeTheBacklogDrains() throws Exception { + Identity author = Identity.generateRandomIdentity(); + try (NostrClient nostr = clientFor(author)) { + nostr.publishTextNote("stored history " + System.nanoTime()); + + AtomicInteger events = new AtomicInteger(); + AtomicInteger backlogDrained = new AtomicInteger(); + try (RelaySubscription subscription = + nostr.subscribe( + List.of(notesBy(author)), + new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + events.incrementAndGet(); + } + + @Override + public void onEndOfStoredEvents() { + backlogDrained.incrementAndGet(); + } + })) { + + assertEquals(0, events.get(), "stored events arrived before subscribe returned"); + assertEquals(0, backlogDrained.get(), "the backlog drained before subscribe returned"); + + await().atMost(30, TimeUnit.SECONDS).until(() -> backlogDrained.get() == 1); + assertEquals(1, backlogDrained.get(), "the backlog signal fired more than once"); + } + } + } + + // Verifies an event published while a subscription is open reaches that subscription, which + // is the whole reason §6.1 keeps long-lived subscriptions rather than polling. + @Test + void anEventPublishedMidSubscriptionReachesTheListener() throws Exception { + Identity author = Identity.generateRandomIdentity(); + String marker = "watch-me-" + System.nanoTime(); + + try (NostrClient nostr = clientFor(author)) { + CountDownLatch sawMarker = new CountDownLatch(1); + List buffered = new CopyOnWriteArrayList<>(); + + try (RelaySubscription subscription = + nostr.subscribe( + List.of(notesBy(author)), + event -> { + buffered.add(event); + if (marker.equals(event.getContent())) { + sawMarker.countDown(); + } + })) { + + nostr.publishTextNote(marker); + + assertTrue(sawMarker.await(30, TimeUnit.SECONDS), "the published event never arrived"); + } + } + } + + // Verifies a recipient's kind-10050 list is resolvable, since §6.2's delivery depends on it + // and a recipient without one cannot be sent to at all. + @Test + void aRecipientsDirectMessageRelaysAreResolvable() throws Exception { + Identity recipient = Identity.generateRandomIdentity(); + + try (NostrClient recipientClient = clientFor(recipient); + NostrClient senderClient = clientFor(Identity.generateRandomIdentity())) { + + publishRelayList(recipientClient, recipient); + + await() + .atMost(30, TimeUnit.SECONDS) + .until(() -> senderClient.findDirectMessageRelays(recipient.getPublicKey()).isPresent()); + } + } + + // Verifies a message to one recipient yields two outcomes, the second being the sender's own + // copy. §6.2 warns the tool not to report that as "1 of 2 delivered". + @Test + void aMessageToOneRecipientReportsTheSendersCopyToo() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + + try (NostrClient recipientClient = clientFor(recipient); + NostrClient senderClient = clientFor(sender)) { + + publishRelayList(recipientClient, recipient); + await() + .atMost(30, TimeUnit.SECONDS) + .until(() -> senderClient.findDirectMessageRelays(recipient.getPublicKey()).isPresent()); + + List outcomes = + senderClient.sendDirectMessage(List.of(recipient.getPublicKey()), "two outcomes"); + + assertEquals(2, outcomes.size(), "NIP-17 requires a copy addressed to the sender"); + assertTrue( + outcomeFor(outcomes, sender).isDelivered() != outcomeFor(outcomes, recipient).isDelivered() + || outcomeFor(outcomes, recipient).isDelivered(), + "the actual recipient was not reached: " + outcomes); + assertTrue( + outcomeFor(outcomes, recipient).isDelivered(), + "the actual recipient was not reached: " + outcomes); + + // The point §6.2 warns about: this sender published no relay list of their own, so + // their archival copy is undeliverable while the message itself arrived. A tool that + // counted "1 of 2 delivered" would report a successful send as a failure. + assertEquals( + RecipientDeliveryOutcome.Status.UNREACHABLE, + outcomeFor(outcomes, sender).status(), + "a sender without a relay list should not be reported as reached: " + outcomes); + } + } + + // Verifies a recipient who published no relay list is reported unreachable rather than + // silently skipped, which §6.2 requires so an agent can tell the user who missed out. + @Test + void aRecipientWithoutARelayListIsReportedUnreachable() throws Exception { + Identity absent = Identity.generateRandomIdentity(); + + try (NostrClient senderClient = clientFor(Identity.generateRandomIdentity())) { + List outcomes = + senderClient.sendDirectMessage(List.of(absent.getPublicKey()), "into the void"); + + assertTrue( + outcomes.stream() + .anyMatch( + outcome -> + outcome.recipient().equals(absent.getPublicKey().toString()) + && outcome.status() == RecipientDeliveryOutcome.Status.UNREACHABLE), + "an unreachable recipient was not reported: " + outcomes); + } + } + + // Verifies an event can be signed by an identity other than the client's default, which + // §6.3's multi-identity server depends on for its per-call identity argument. + @Test + void publishingCanSignAsAnotherIdentity() throws Exception { + Identity defaultIdentity = Identity.generateRandomIdentity(); + Identity otherAccount = Identity.generateRandomIdentity(); + + try (NostrClient nostr = clientFor(defaultIdentity)) { + GenericEvent event = + GenericEvent.builder() + .pubKey(otherAccount.getPublicKey()) + .kind(TEXT_NOTE_KIND) + .content("posted for another account") + .build(); + + nostr.publishAs(otherAccount, event); + + assertEquals(otherAccount.getPublicKey(), event.getPubKey()); + } + } + + // Verifies a broker-style connection can serve two independent pools over one websocket, + // which §6.3.1 claims is a configuration change rather than a code change. + @Test + void oneConnectionCanServeSeveralPools() throws Exception { + AtomicInteger connectionsOpened = new AtomicInteger(); + AtomicInteger poolsServed = new AtomicInteger(); + + try (RelayConnection shared = openConnection(connectionsOpened)) { + RelayConnectionFactory brokered = relayUri -> sharing(shared, poolsServed); + + RelayPool second; + try (RelayPool first = new RelayPool(List.of(relayUri()), brokered)) { + second = new RelayPool(List.of(relayUri()), brokered); + assertEquals(List.of(relayUri()), first.publish(signedNote("from one")).getAcceptingRelays()); + } + + // The first pool has closed its view of the shared connection. If that closed the + // connection underneath, the second pool is now broken: that is the whole point of a + // broker owning the transport rather than any one pool. + try (RelayPool stillWorking = second) { + assertEquals( + List.of(relayUri()), + stillWorking.publish(signedNote("from two")).getAcceptingRelays(), + "closing one pool tore down the shared connection"); + } + } + + assertEquals(1, connectionsOpened.get(), "the broker opened more than one websocket"); + assertEquals(2, poolsServed.get(), "both pools were not served by the broker"); + } + + // Verifies a NIP-02 follow list survives a real relay with its relay hints and petnames + // intact, which is what §12 relies on when calling nostr_get_contacts adapter work. + @Test + void aContactListRoundTripsThroughARelay() throws Exception { + Identity owner = Identity.generateRandomIdentity(); + PublicKey friend = Identity.generateRandomIdentity().getPublicKey(); + ContactList published = + new ContactList( + owner.getPublicKey(), + List.of(new Contact(friend, new Relay(relayUri()), "friend")), + System.currentTimeMillis() / 1000); + + try (NostrClient nostr = clientFor(owner)) { + nostr.publish(published.toEvent()); + + AtomicReference readBack = new AtomicReference<>(); + try (RelaySubscription subscription = + nostr.subscribe( + List.of( + EventFilter.builder() + .author(owner.getPublicKey().toString()) + .kind(CONTACT_LIST_KIND) + .build()), + readBack::set)) { + + await().atMost(30, TimeUnit.SECONDS).until(() -> readBack.get() != null); + } + + Contact recovered = ContactList.from(readBack.get()).getContacts().getFirst(); + assertEquals(friend, recovered.getPublicKey(), "the followed key did not survive"); + assertEquals(relayUri(), recovered.findRelay().orElseThrow().getUri(), "the relay hint was lost"); + assertEquals("friend", recovered.findPetname().orElseThrow(), "the petname was lost"); + } + } + + private GenericEvent signedNote(String content) { + Identity author = Identity.generateRandomIdentity(); + GenericEvent event = + GenericEvent.builder().pubKey(author.getPublicKey()).kind(TEXT_NOTE_KIND).content(content).build(); + author.sign(event); + return event; + } + + private RelayConnection openConnection(AtomicInteger connectionsOpened) throws Exception { + connectionsOpened.incrementAndGet(); + return new NostrRelayClient(relayUri(), 8_000L); + } + + /** A connection handed to a pool that must not close the shared one underneath it. */ + private RelayConnection sharing(RelayConnection shared, AtomicInteger poolsServed) { + poolsServed.incrementAndGet(); + return new RelayConnection() { + @Override + public String getRelayUri() { + return shared.getRelayUri(); + } + + @Override + public ConnectionState getConnectionState() { + return shared.getConnectionState(); + } + + @Override + public List send(T message) throws IOException { + return shared.send(message); + } + + @Override + public AutoCloseable subscribe( + T requestMessage, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException { + return shared.subscribe(requestMessage, messageListener, errorListener, closeListener); + } + + @Override + public void close() { + // The broker owns the underlying connection; a pool releasing its view must not close it. + } + }; + } + + private RecipientDeliveryOutcome outcomeFor( + List outcomes, Identity participant) { + return outcomes.stream() + .filter(outcome -> outcome.recipient().equals(participant.getPublicKey().toString())) + .findFirst() + .orElseThrow(() -> new AssertionError("No outcome for " + participant.getPublicKey())); + } + + private void publishRelayList(NostrClient client, Identity owner) throws Exception { + client.publish( + GenericEvent.builder() + .pubKey(owner.getPublicKey()) + .kind(DIRECT_MESSAGE_RELAY_LIST_KIND) + .content("") + .tags(List.of(GenericTag.of("relay", relayUri()))) + .build()); + } + + private EventFilter notesBy(Identity author) { + return EventFilter.builder() + .author(author.getPublicKey().toString()) + .kind(TEXT_NOTE_KIND) + .build(); + } + + private NostrClient clientFor(Identity identity) { + return NostrClient.builder().identity(identity).relays(relayUri()).build(); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(RELAY_PORT); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/NostrClientRoundTripIT.java b/nostr-java-api/src/test/java/nostr/api/integration/NostrClientRoundTripIT.java new file mode 100644 index 00000000..582486ab --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/NostrClientRoundTripIT.java @@ -0,0 +1,174 @@ +package nostr.api.integration; + +import nostr.api.NostrClient; +import nostr.api.RecipientDeliveryOutcome; +import nostr.client.relay.PublishResult; +import nostr.event.filter.EventFilter; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the client against a real relay over a real WebSocket. + * + *

Everything else in this module is verified against scripted relay behaviour, which is the + * right way to reproduce relays that disagree but never opens a socket. This test closes that + * gap: it publishes an event and reads it back, so the encoding, the transport and the + * subscription machinery are all shown to work against software that was not written to satisfy + * these tests. + */ +@Testcontainers +class NostrClientRoundTripIT { + + private static final DockerImageName RELAY_IMAGE = + DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13"); + private static final int RELAY_PORT = 8080; + + /** Retries for a relay whose startup panic leaves it accepting connections but inert. */ + private static final int RELAY_STARTUP_ATTEMPTS = 5; + + /** + * The relay, held until it has proved it can store an event. + * + *

Neither the port nor the startup log is sufficient evidence of readiness. The port binds + * before database migration finishes, and on some hardware a worker thread panics during + * startup ({@code po2_denom was zero!}, a timing crate miscalibrating against the CPU clock) + * after which the relay still accepts connections but silently answers nothing. Both produce a + * publish that hangs until it times out, which looks exactly like a client bug. + * + *

The only dependable signal is the behaviour the tests need, so the wait publishes a + * throwaway event and requires an {@code OK}. A relay left inert by the startup panic can never + * satisfy that, so the container is started again rather than waited on indefinitely. + */ + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(RELAY_IMAGE) + .withExposedPorts(RELAY_PORT) + .withStartupAttempts(RELAY_STARTUP_ATTEMPTS) + .waitingFor( + new RelayStoresEventsWaitStrategy() + .withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies an event published through the client can be read back from the relay it was sent + // to, proving encoding, transport and subscription work end to end. + @Test + void anEventPublishedIsReadBackFromTheRelay() throws Exception { + Identity author = Identity.generateRandomIdentity(); + String content = "round trip " + System.nanoTime(); + + try (NostrClient nostr = NostrClient.builder().identity(author).relays(relayUri()).build()) { + PublishResult published = nostr.publishTextNote(content); + assertEquals(List.of(relayUri()), published.getAcceptingRelays()); + + List received = new ArrayList<>(); + try (var subscription = + nostr.subscribe( + List.of(EventFilter.builder().author(author.getPublicKey().toString()).build()), + received::add)) { + + await() + .atMost(30, TimeUnit.SECONDS) + .until(() -> received.stream().anyMatch(event -> content.equals(event.getContent()))); + } + } + } + + // Verifies a private direct message survives a real round trip: delivered to the recipient's + // own relay, retrieved by them, and unwrapped back into the text that was sent. + @Test + void aDirectMessageIsDeliveredAndReadBack() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + String content = "private " + System.nanoTime(); + + try (NostrClient recipientClient = + NostrClient.builder().identity(recipient).relays(relayUri()).build(); + NostrClient senderClient = + NostrClient.builder().identity(sender).relays(relayUri()).build()) { + + publishDirectMessageRelayList(recipientClient, recipient); + awaitRelayListVisible(senderClient, recipient); + + List outcomes = + senderClient.sendDirectMessage(List.of(recipient.getPublicKey()), content); + assertTrue( + outcomes.stream().anyMatch(RecipientDeliveryOutcome::isDelivered), + "no participant received the message: " + outcomes); + + AtomicBoolean readBack = new AtomicBoolean(); + try (var subscription = + recipientClient.subscribe( + List.of(EventFilter.builder().kind(GIFT_WRAP_KIND).build()), + wrap -> readBackMatches(recipientClient, wrap, content, readBack))) { + + await().atMost(30, TimeUnit.SECONDS).until(readBack::get); + } + } + } + + /** + * Unwrap a received gift wrap, ignoring those addressed to somebody else. + * + *

A relay serves every wrap it holds, and only the ones sealed to this recipient can be + * opened, so failing to unwrap is expected rather than a problem. + */ + private void readBackMatches( + NostrClient recipientClient, GenericEvent wrap, String content, AtomicBoolean readBack) { + try { + ChatMessage message = recipientClient.readDirectMessage(wrap); + if (content.equals(message.getContent())) { + readBack.set(true); + } + } catch (RuntimeException notForThisRecipient) { + // Wraps addressed to others cannot be opened, which is the point of the scheme. + } + } + + /** + * Wait until the sender can actually see the recipient's relay list. + * + *

Publishing returns once a relay accepts the event, which is not the same as the relay + * having indexed it for queries. Sending before then would report the recipient unreachable + * for a reason that says nothing about the code under test. + */ + private void awaitRelayListVisible(NostrClient sender, Identity recipient) { + await() + .atMost(30, TimeUnit.SECONDS) + .until(() -> sender.findDirectMessageRelays(recipient.getPublicKey()).isPresent()); + } + + private void publishDirectMessageRelayList(NostrClient client, Identity owner) throws Exception { + client.publish( + GenericEvent.builder() + .pubKey(owner.getPublicKey()) + .kind(DIRECT_MESSAGE_RELAY_LIST_KIND) + .content("") + .tags(List.of(GenericTag.of("relay", relayUri()))) + .build()); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(RELAY_PORT); + } + + private static final int GIFT_WRAP_KIND = 1059; + private static final int DIRECT_MESSAGE_RELAY_LIST_KIND = 10050; +} diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 85daf838..faf155bb 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.3.1 ../pom.xml @@ -94,5 +94,28 @@ spring-test test + + org.testcontainers + testcontainers + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + diff --git a/nostr-java-client/src/main/java/nostr/client/relay/DeliveredEventWindow.java b/nostr-java-client/src/main/java/nostr/client/relay/DeliveredEventWindow.java new file mode 100644 index 00000000..dc0519fc --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/DeliveredEventWindow.java @@ -0,0 +1,56 @@ +package nostr.client.relay; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Remembers which event identifiers have already been delivered, forgetting the oldest first. + * + *

Every relay holding an event sends it, so a five-relay subscription would otherwise show + * each note five times. Remembering every identifier forever would fix that and leak, on exactly + * the long-lived firehose subscriptions that need de-duplication most, so the window is bounded + * and the oldest identifiers are evicted. + * + *

Eviction means an event can be re-delivered if its copies arrive further apart than the + * window is wide. The default is sized well beyond any realistic spread between relays. + */ +final class DeliveredEventWindow { + + /** Identifiers remembered by default: comfortably beyond cross-relay arrival spread. */ + static final int DEFAULT_CAPACITY = 4_096; + + private final Map deliveredEventIds; + + DeliveredEventWindow(int capacity) { + if (capacity < 1) { + throw new IllegalArgumentException("capacity must be positive, was " + capacity); + } + this.deliveredEventIds = + new LinkedHashMap<>(capacity, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } + }; + } + + /** + * Record an event identifier, reporting whether it is the first time it has been seen. + * + * @param eventId the identifier to record + * @return {@code true} when this identifier had not been delivered, so the caller should + * deliver it now + */ + synchronized boolean isFirstSighting(String eventId) { + return deliveredEventIds.put(eventId, Boolean.TRUE) == null; + } + + /** + * How many identifiers are currently remembered. + * + * @return the window's occupancy, never above its capacity + */ + synchronized int size() { + return deliveredEventIds.size(); + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/NoRelayAcceptedException.java b/nostr-java-client/src/main/java/nostr/client/relay/NoRelayAcceptedException.java new file mode 100644 index 00000000..ae3e9376 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/NoRelayAcceptedException.java @@ -0,0 +1,47 @@ +package nostr.client.relay; + +import lombok.Getter; + +import java.io.IOException; +import java.util.stream.Collectors; + +/** + * Thrown when an event was published to relays and not one of them stored it. + * + *

Partial failure is ordinary and is reported in a {@link PublishResult}. Total failure is + * not: an event that reached no relay does not exist as far as the network is concerned, and a + * caller that ignored a returned value would carry on believing it had published. This + * mirrors the reasoning behind {@code RelayTimeoutException}, which replaced silently returning + * an empty list when a relay never answered. + * + *

The full {@link PublishResult} is attached, so the caller can still see what each relay + * said rather than only that everything failed. + */ +@Getter +public class NoRelayAcceptedException extends IOException { + + private final transient PublishResult publishResult; + + /** + * @param publishResult the per-relay outcomes, none of which was an acceptance + */ + public NoRelayAcceptedException(PublishResult publishResult) { + super(describe(publishResult)); + this.publishResult = publishResult; + } + + private static String describe(PublishResult publishResult) { + String failures = + publishResult.getFailures().stream() + .map( + outcome -> + outcome.relayUri() + + " (" + + outcome.status() + + outcome.findReason().map(reason -> ": " + reason).orElse("") + + ")") + .collect(Collectors.joining(", ")); + return "No relay accepted event %s. Attempted: %s" + .formatted(publishResult.getEventId(), failures); + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/PublishResult.java b/nostr-java-client/src/main/java/nostr/client/relay/PublishResult.java new file mode 100644 index 00000000..99cadaac --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/PublishResult.java @@ -0,0 +1,120 @@ +package nostr.client.relay; + +import lombok.NonNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * What every relay did with one published event. + * + *

Publishing across relays succeeds partially far more often than it succeeds completely, so + * the result is a record to inspect rather than a value to ignore. A caller that only wants to + * know the event got out reads {@link #isAccepted()}; one that must report delivery reads the + * per-relay outcomes. + * + *

Total failure is not represented here. A publish where no relay accepted throws + * {@link NoRelayAcceptedException}, so an unpublished event cannot be mistaken for a published + * one by a caller that skipped the result. + */ +public final class PublishResult { + + private final String eventId; + private final Map outcomesByRelay; + + private PublishResult(String eventId, Map outcomesByRelay) { + this.eventId = eventId; + this.outcomesByRelay = outcomesByRelay; + } + + /** + * Collect per-relay outcomes into a result. + * + * @param eventId the published event's identifier + * @param outcomes each relay's outcome + * @return the assembled result + */ + public static PublishResult of( + @NonNull String eventId, @NonNull List outcomes) { + Map byRelay = new LinkedHashMap<>(); + outcomes.forEach(outcome -> byRelay.put(outcome.relayUri(), outcome)); + return new PublishResult(eventId, Collections.unmodifiableMap(byRelay)); + } + + /** + * The identifier of the event this result describes. + * + * @return the event id + */ + public String getEventId() { + return eventId; + } + + /** + * Every relay's outcome. + * + * @return the outcomes, one per relay the event was sent to + */ + public List getOutcomes() { + return List.copyOf(outcomesByRelay.values()); + } + + /** + * One relay's outcome. + * + * @param relayUri the relay to look up + * @return that relay's outcome, or empty if the event was never sent there + */ + public Optional findOutcome(@NonNull String relayUri) { + return Optional.ofNullable(outcomesByRelay.get(relayUri)); + } + + /** + * The relays that stored the event. + * + * @return the accepting relays' URIs + */ + public List getAcceptingRelays() { + return outcomesByRelay.values().stream() + .filter(RelayPublishOutcome::isAccepted) + .map(RelayPublishOutcome::relayUri) + .toList(); + } + + /** + * The relays that did not store the event, whether they rejected, timed out, or were down. + * + * @return the failing relays' outcomes + */ + public List getFailures() { + return outcomesByRelay.values().stream().filter(outcome -> !outcome.isAccepted()).toList(); + } + + /** + * Whether at least one relay stored the event. + * + * @return {@code true} when the event reached at least one relay + */ + public boolean isAccepted() { + return outcomesByRelay.values().stream().anyMatch(RelayPublishOutcome::isAccepted); + } + + /** + * Whether every relay stored the event. + * + * @return {@code true} when no relay rejected, timed out, or was unreachable + */ + public boolean isAcceptedByAllRelays() { + return !outcomesByRelay.isEmpty() + && outcomesByRelay.values().stream().allMatch(RelayPublishOutcome::isAccepted); + } + + @Override + public String toString() { + return "PublishResult[event=%s accepted=%d of %d]" + .formatted(eventId, getAcceptingRelays().size(), outcomesByRelay.size()); + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/RelayConnection.java b/nostr-java-client/src/main/java/nostr/client/relay/RelayConnection.java new file mode 100644 index 00000000..32144ef9 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/RelayConnection.java @@ -0,0 +1,76 @@ +package nostr.client.relay; + +import nostr.client.springwebsocket.ConnectionState; +import nostr.event.BaseMessage; + +import java.io.IOException; +import java.util.List; +import java.util.function.Consumer; + +/** + * A single relay connection, as seen by code that coordinates several relays. + * + *

This is the seam between relay coordination and relay transport. It exposes only what a + * caller managing many relays needs — identify, send, subscribe, observe state, close — rather + * than mirroring the full surface of any one transport implementation. Modules above this + * interface can therefore be tested against scripted relay behaviour without opening a socket. + * + *

The interface deliberately names no WebSocket or Spring type of its own. It does reuse + * {@link ConnectionState}, which already belongs to the transport package because it was public + * API before this seam existed; moving it would break existing callers for no gain. + * + *

Implementations are expected to be safe for use from several threads, but they may permit + * only one request in flight at a time; callers coordinating many relays are responsible for + * respecting that by serialising their own requests per connection. + */ +public interface RelayConnection extends AutoCloseable { + + /** + * The URI of the relay this connection talks to. + * + * @return the relay WebSocket URI, used to identify the connection in a pool and in logs + */ + String getRelayUri(); + + /** + * The current state of the underlying connection. + * + * @return the connection state, so callers can distinguish a relay that rejected a request + * from one that was never reachable + */ + ConnectionState getConnectionState(); + + /** + * Send a message and wait for the relay's response frames. + * + * @param message the message to send + * @return the raw response payloads the relay returned before completing the request + * @throws IOException if the message could not be sent or the relay did not respond in time + */ + List send(T message) throws IOException; + + /** + * Register a long-lived subscription and stream matching payloads to a listener. + * + * @param requestMessage the subscription request + * @param messageListener receives each inbound payload for this subscription + * @param errorListener receives transport errors affecting this subscription + * @param closeListener invoked when the underlying connection closes, may be {@code null} + * @return a handle that cancels the subscription when closed + * @throws IOException if the subscription could not be registered + */ + AutoCloseable subscribe( + T requestMessage, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException; + + /** + * Close the connection and release its resources. + * + * @throws IOException if the connection could not be closed cleanly + */ + @Override + void close() throws IOException; +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/RelayConnectionFactory.java b/nostr-java-client/src/main/java/nostr/client/relay/RelayConnectionFactory.java new file mode 100644 index 00000000..01a799d1 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/RelayConnectionFactory.java @@ -0,0 +1,22 @@ +package nostr.client.relay; + +import java.io.IOException; + +/** + * Creates a {@link RelayConnection} for a relay URI. + * + *

Coordinating code depends on this factory rather than constructing connections directly, + * so that tests can supply scripted relay behaviour in place of real WebSocket transport. + */ +@FunctionalInterface +public interface RelayConnectionFactory { + + /** + * Open a connection to the given relay. + * + * @param relayUri the relay WebSocket URI + * @return a connected {@link RelayConnection} + * @throws IOException if the relay could not be reached + */ + RelayConnection connect(String relayUri) throws IOException; +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/RelayPool.java b/nostr-java-client/src/main/java/nostr/client/relay/RelayPool.java new file mode 100644 index 00000000..06a6b202 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/RelayPool.java @@ -0,0 +1,572 @@ +package nostr.client.relay; + +import lombok.extern.slf4j.Slf4j; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.event.json.codec.BaseMessageDecoder; +import nostr.event.message.EventMessage; +import nostr.event.message.OkMessage; +import nostr.client.springwebsocket.ConnectionState; +import nostr.client.springwebsocket.RelayTimeoutException; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A set of relay connections that one event can be published to at once. + * + *

Nostr is a multi-relay protocol, but a single connection can only ever give a single + * answer. This pool fans a publish out across its members concurrently and collects what each + * one said into a {@link PublishResult}, so partial delivery is visible rather than hidden + * behind a boolean. + * + *

Construction is best effort: relays that cannot be reached are recorded as + * down rather than aborting the pool, because one dead relay must never stop an application + * starting. A publish waits for every relay's answer up to {@link #DEFAULT_PUBLISH_TIMEOUT}, + * after which silent relays are recorded as timed out, so one slow relay cannot hang the call. + */ +@Slf4j +public class RelayPool implements AutoCloseable { + + /** How long a publish waits for a relay before recording it as timed out. */ + public static final Duration DEFAULT_PUBLISH_TIMEOUT = Duration.ofSeconds(10); + + /** How often downed relays are retried in the background. */ + public static final Duration DEFAULT_RECONNECT_INTERVAL = Duration.ofSeconds(30); + + /** How long a subscription waits for every relay to replay its backlog. */ + public static final Duration DEFAULT_BACKLOG_TIMEOUT = Duration.ofSeconds(10); + + /** + * Live connections, keyed by relay URI. + * + *

Concurrent rather than ordered because membership changes while publishes are in flight: + * a recovering relay rejoins from another thread, and iterating a plain map at that moment + * throws. Configured order is preserved separately in {@link #configuredRelayUris}, so results + * stay predictable without making readers pay for a lock. + */ + private final Map connectionsByRelay = new ConcurrentHashMap<>(); + + private final Map unreachableRelays = new ConcurrentHashMap<>(); + + /** + * The relays in the pool, in the order they joined, so outcomes are reported predictably. + * + *

Copy-on-write because membership changes while publishes iterate it: a relay added for a + * single delivery, or one rejoining after an outage, must not disturb work already in flight. + */ + private final List configuredRelayUris = new CopyOnWriteArrayList<>(); + + /** + * How many callers still need each transiently added relay. + * + *

A relay added to deliver one message must be released afterwards, but two deliveries to + * the same recipient may overlap. Counting holders means the second does not close the + * connection the first is still using. + */ + private final Map transientRelayHolders = new ConcurrentHashMap<>(); + + /** + * Retries downed relays on a schedule the pool owns. + * + *

Relays go down and come back, and an application must not need restarting to notice, so + * reconnection belongs to the pool rather than to whoever remembers to call it. + */ + private final ScheduledExecutorService reconnectScheduler; + + /** Live subscriptions, kept so relays that rejoin can be re-subscribed from their filters. */ + private final Set subscriptions = ConcurrentHashMap.newKeySet(); + + private final Duration backlogTimeout; + private final AtomicLong subscriptionSequence = new AtomicLong(); + /** + * One lock per relay, because a connection serves one request at a time. + * + *

{@code NostrRelayClient} rejects a second concurrent {@code send} on the same connection, + * so without this two callers publishing at once would collide. Locking per relay rather than + * across the pool keeps fan-out concurrent: a slow relay delays only its own queue. + */ + private final Map locksByRelay = new ConcurrentHashMap<>(); + private final RelayConnectionFactory connectionFactory; + private final Duration publishTimeout; + private final BaseMessageDecoder okMessageDecoder = new BaseMessageDecoder<>(); + + /** + * Connect to each relay, keeping those that answer. + * + * @param relayUris the relays to connect to + * @param connectionFactory opens a connection for a relay URI + * @param publishTimeout how long a publish waits before recording a relay as timed out + */ + public RelayPool( + List relayUris, + RelayConnectionFactory connectionFactory, + Duration publishTimeout) { + this(relayUris, connectionFactory, publishTimeout, DEFAULT_RECONNECT_INTERVAL); + } + + /** + * Connect to each relay, retrying the ones that are down at the given interval. + * + * @param relayUris the relays to connect to + * @param connectionFactory opens a connection for a relay URI + * @param publishTimeout how long a publish waits before recording a relay as timed out + * @param reconnectInterval how often downed relays are retried in the background + */ + public RelayPool( + List relayUris, + RelayConnectionFactory connectionFactory, + Duration publishTimeout, + Duration reconnectInterval) { + this(relayUris, connectionFactory, publishTimeout, reconnectInterval, DEFAULT_BACKLOG_TIMEOUT); + } + + /** + * Connect to each relay, choosing every timing the pool observes. + * + * @param relayUris the relays to connect to + * @param connectionFactory opens a connection for a relay URI + * @param publishTimeout how long a publish waits before recording a relay as timed out + * @param reconnectInterval how often downed relays are retried in the background + * @param backlogTimeout how long a subscription waits for every relay to replay stored events + */ + public RelayPool( + List relayUris, + RelayConnectionFactory connectionFactory, + Duration publishTimeout, + Duration reconnectInterval, + Duration backlogTimeout) { + this.backlogTimeout = Objects.requireNonNull(backlogTimeout, "backlogTimeout"); + Objects.requireNonNull(relayUris, "relayUris"); + Objects.requireNonNull(connectionFactory, "connectionFactory"); + this.publishTimeout = Objects.requireNonNull(publishTimeout, "publishTimeout"); + this.connectionFactory = connectionFactory; + this.configuredRelayUris.addAll(relayUris); + relayUris.forEach(relayUri -> connectOrRecordAsDown(relayUri, connectionFactory)); + this.reconnectScheduler = startReconnecting(reconnectInterval); + } + + private ScheduledExecutorService startReconnecting(Duration reconnectInterval) { + Objects.requireNonNull(reconnectInterval, "reconnectInterval"); + ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor( + runnable -> Thread.ofVirtual().name("nostr-relay-reconnect").unstarted(runnable)); + scheduler.scheduleWithFixedDelay( + this::reconnectDownedRelaysQuietly, + reconnectInterval.toMillis(), + reconnectInterval.toMillis(), + TimeUnit.MILLISECONDS); + return scheduler; + } + + private void reconnectDownedRelaysQuietly() { + try { + retryUnreachableRelays(); + } catch (RuntimeException e) { + log.warn("Background relay reconnection failed: {}", e.getMessage()); + } + } + + /** + * Connect to each relay using the default publish timeout. + * + * @param relayUris the relays to connect to + * @param connectionFactory opens a connection for a relay URI + */ + public RelayPool(List relayUris, RelayConnectionFactory connectionFactory) { + this(relayUris, connectionFactory, DEFAULT_PUBLISH_TIMEOUT); + } + + private void connectOrRecordAsDown(String relayUri, RelayConnectionFactory connectionFactory) { + try { + connectionsByRelay.put(relayUri, connectionFactory.connect(relayUri)); + } catch (IOException e) { + log.warn("Relay {} is unreachable and was not added to the pool: {}", relayUri, e.getMessage()); + unreachableRelays.put(relayUri, e.getMessage()); + } + } + + /** + * Publish an event to every connected relay and report what each one did. + * + * @param event the signed event to publish + * @return each relay's outcome + * @throws NoRelayAcceptedException when not one relay stored the event + */ + public PublishResult publish(GenericEvent event) throws NoRelayAcceptedException { + Objects.requireNonNull(event, "event"); + PublishResult result = PublishResult.of(event.getId(), collectOutcomes(event)); + if (!result.isAccepted()) { + throw new NoRelayAcceptedException(result); + } + return result; + } + + private List collectOutcomes(GenericEvent event) { + List outcomes = new ArrayList<>(sendConcurrently(event)); + unreachableRelays.forEach( + (relayUri, reason) -> outcomes.add(RelayPublishOutcome.unreachable(relayUri, reason))); + return outcomes; + } + + /** + * Send to every relay at once and gather what each one said. + * + *

A relay that misses the timeout has its task cancelled, which interrupts the sending + * thread. A transport that ignores interruption keeps running after this method returns: the + * caller is still protected, because the timeout is honoured regardless, but the thread is + * orphaned until its own I/O gives up. This is acceptable because the alternative, waiting for + * it, would let one stuck relay defeat the timeout that exists to bound exactly that. + */ + private List sendConcurrently(GenericEvent event) { + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + Map> pending = new LinkedHashMap<>(); + configuredRelayUris.forEach( + relayUri -> { + RelayConnection connection = connectionsByRelay.get(relayUri); + if (connection != null) { + pending.put(relayUri, executor.submit(sendTo(connection, event))); + } + }); + Instant deadline = Instant.now().plus(publishTimeout); + return pending.entrySet().stream() + .map(entry -> awaitOutcome(entry.getKey(), entry.getValue(), deadline)) + .toList(); + } + } + + private Callable sendTo(RelayConnection connection, GenericEvent event) { + return () -> { + ReentrantLock relayLock = + locksByRelay.computeIfAbsent(connection.getRelayUri(), uri -> new ReentrantLock(true)); + relayLock.lock(); + try { + return interpretResponses( + connection.getRelayUri(), event.getId(), connection.send(new EventMessage(event))); + } catch (RelayTimeoutException e) { + return RelayPublishOutcome.timedOut(connection.getRelayUri(), e.getMessage()); + } catch (IOException e) { + return RelayPublishOutcome.unreachable(connection.getRelayUri(), e.getMessage()); + } finally { + relayLock.unlock(); + } + }; + } + + /** + * Wait for one relay's answer, but never past the deadline the whole publish shares. + * + *

The budget belongs to the publish, not to each relay: spending the full timeout on each + * relay in turn would let five stalled relays cost five times the wait the caller asked for. + */ + private RelayPublishOutcome awaitOutcome( + String relayUri, Future pending, Instant deadline) { + long remainingMillis = Math.max(0, Duration.between(Instant.now(), deadline).toMillis()); + try { + return pending.get(remainingMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + pending.cancel(true); + return RelayPublishOutcome.timedOut( + relayUri, "No answer within " + publishTimeout.toMillis() + "ms"); + } catch (ExecutionException e) { + return RelayPublishOutcome.timedOut(relayUri, String.valueOf(e.getCause())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return RelayPublishOutcome.timedOut(relayUri, "Interrupted while awaiting relay answer"); + } + } + + /** + * Read the relay's answer for one specific event. + * + *

A connection can carry answers for more than one event, so the {@code OK} is matched on + * the event id rather than taken as the first {@code OK} seen: crediting another event's + * acceptance to this one would report a publish that never happened. + */ + private RelayPublishOutcome interpretResponses( + String relayUri, String eventId, List responses) { + return responses.stream() + .filter(payload -> payload != null && payload.startsWith("[\"OK\"")) + .map(this::decodeOkMessage) + .filter(okMessage -> okMessage != null && eventId.equals(okMessage.getEventId())) + .findFirst() + .map(okMessage -> describeOutcome(relayUri, okMessage)) + .orElseGet( + () -> RelayPublishOutcome.timedOut(relayUri, "Relay sent no OK for event " + eventId)); + } + + private OkMessage decodeOkMessage(String payload) { + try { + return okMessageDecoder.decode(payload); + } catch (RuntimeException e) { + log.warn("Ignoring unreadable OK from relay: {}", e.getMessage()); + return null; + } + } + + private RelayPublishOutcome describeOutcome(String relayUri, OkMessage okMessage) { + return Boolean.TRUE.equals(okMessage.getFlag()) + ? RelayPublishOutcome.accepted(relayUri) + : RelayPublishOutcome.rejected(relayUri, okMessage.getMessage()); + } + + /** + * The relays currently connected. + * + * @return the connected relays' URIs, in the order they were configured + */ + public List getConnectedRelays() { + return configuredRelayUris.stream() + .filter( + relayUri -> + getConnectionState(relayUri).orElse(null) == ConnectionState.CONNECTED) + .toList(); + } + + /** + * One relay's connection state. + * + * @param relayUri the relay to look up + * @return that relay's state, or empty when the relay is not in the pool + */ + public Optional getConnectionState(String relayUri) { + return Optional.ofNullable(connectionsByRelay.get(relayUri)) + .map(RelayConnection::getConnectionState); + } + + /** + * Try the relays that were unreachable again, returning any that now answer to service. + * + *

Relays go down and come back, and an application should not need restarting to notice. + * Callers decide when to retry; the pool does not impose a schedule of its own. + * + * @return the relays that rejoined + */ + public List retryUnreachableRelays() { + markDroppedRelaysAsUnreachable(); + List rejoined = new ArrayList<>(); + getUnreachableRelays() + .forEach( + relayUri -> { + try { + RelayConnection reconnected = connectionFactory.connect(relayUri); + connectionsByRelay.put(relayUri, reconnected); + unreachableRelays.remove(relayUri); + rejoined.add(relayUri); + resumeSubscriptionsOn(reconnected); + log.info("Relay {} recovered and rejoined the pool", relayUri); + } catch (IOException e) { + log.debug("Relay {} is still unreachable: {}", relayUri, e.getMessage()); + } + }); + return List.copyOf(rejoined); + } + + /** + * Add a relay to the running pool, connecting it if it is not already a member. + * + *

Relay sets are not fixed: a user changes their preferences, and a direct message must be + * delivered to relays the recipient chose rather than the ones this pool was built with. + * Adding an existing relay records another holder rather than opening a second connection. + * + * @param relayUri the relay to add + * @return {@code true} if the relay is now available for use + */ + public boolean addRelay(String relayUri) { + Objects.requireNonNull(relayUri, "relayUri"); + transientRelayHolders.computeIfAbsent(relayUri, uri -> new AtomicInteger()).incrementAndGet(); + if (connectionsByRelay.containsKey(relayUri)) { + return true; + } + if (!configuredRelayUris.contains(relayUri)) { + configuredRelayUris.add(relayUri); + } + connectOrRecordAsDown(relayUri, connectionFactory); + RelayConnection connection = connectionsByRelay.get(relayUri); + if (connection == null) { + return false; + } + resumeSubscriptionsOn(connection); + return true; + } + + /** + * Release a relay added with {@link #addRelay}, closing it once no caller still needs it. + * + *

The connection survives while another holder remains, so overlapping deliveries to the + * same relay do not cut each other off. + * + * @param relayUri the relay to release + * @return {@code true} if the relay was closed and removed from the pool + */ + public boolean releaseRelay(String relayUri) { + Objects.requireNonNull(relayUri, "relayUri"); + AtomicInteger holders = transientRelayHolders.get(relayUri); + if (holders != null && holders.decrementAndGet() > 0) { + return false; + } + transientRelayHolders.remove(relayUri); + return removeRelay(relayUri); + } + + /** + * Remove a relay from the pool immediately, closing its connection. + * + * @param relayUri the relay to remove + * @return {@code true} if the relay was in the pool + */ + public boolean removeRelay(String relayUri) { + Objects.requireNonNull(relayUri, "relayUri"); + configuredRelayUris.remove(relayUri); + unreachableRelays.remove(relayUri); + transientRelayHolders.remove(relayUri); + locksByRelay.remove(relayUri); + RelayConnection removed = connectionsByRelay.remove(relayUri); + if (removed == null) { + return false; + } + closeQuietly(removed); + return true; + } + + /** + * The relays currently in the pool, whether connected or awaiting reconnection. + * + * @return the member relays' URIs, in the order they joined + */ + public List getRelays() { + return List.copyOf(configuredRelayUris); + } + + /** + * Open a subscription on every relay in the pool, delivered as one de-duplicated stream. + * + *

The subscription is retained by the pool so that a relay which drops and later recovers + * is re-subscribed from the same filter, rather than silently ceasing to contribute. + * + * @param filters what to subscribe to + * @param listener receives events, the end-of-backlog signal, and per-relay failures + * @return the subscription, which unsubscribes from every relay when closed + */ + public RelaySubscription subscribe(List filters, SubscriptionListener listener) { + return subscribe(filters, listener, DeliveredEventWindow.DEFAULT_CAPACITY); + } + + /** + * Open a subscription, choosing how many event identifiers to remember for de-duplication. + * + * @param filters what to subscribe to + * @param listener receives events, the end-of-backlog signal, and per-relay failures + * @param deduplicationWindowSize how many recently delivered event ids to remember + * @return the subscription, which unsubscribes from every relay when closed + */ + public RelaySubscription subscribe( + List filters, SubscriptionListener listener, int deduplicationWindowSize) { + RelaySubscription subscription = + new RelaySubscription( + "sub-" + subscriptionSequence.incrementAndGet(), + filters, + listener, + deduplicationWindowSize); + subscriptions.removeIf(RelaySubscription::isClosed); + subscriptions.add(subscription); + configuredRelayUris.forEach( + relayUri -> subscribeQuietly(subscription, connectionsByRelay.get(relayUri), relayUri)); + scheduleBacklogTimeout(subscription); + return subscription; + } + + private void subscribeQuietly( + RelaySubscription subscription, RelayConnection connection, String relayUri) { + if (connection == null) { + return; + } + try { + subscription.subscribeOn(connection); + } catch (IOException e) { + log.warn("Relay {} refused a subscription: {}", relayUri, e.getMessage()); + } + } + + /** + * Give up waiting for relays that never replay their backlog. + * + *

Without this a single unresponsive relay would withhold the end-of-backlog signal + * indefinitely, leaving an application showing a loading state forever. + */ + private void scheduleBacklogTimeout(RelaySubscription subscription) { + reconnectScheduler.schedule( + subscription::stopAwaitingBacklog, backlogTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private void resumeSubscriptionsOn(RelayConnection connection) { + subscriptions.stream() + .filter(subscription -> subscription.isMissing(connection.getRelayUri())) + .forEach( + subscription -> + subscribeQuietly(subscription, connection, connection.getRelayUri())); + } + + /** + * Move relays whose connection has since closed back into the downed set. + * + *

Without this only startup failures would ever be retried, so a relay that dropped after + * connecting would be published to forever without reconnecting. + */ + private void markDroppedRelaysAsUnreachable() { + connectionsByRelay.forEach( + (relayUri, connection) -> { + if (connection.getConnectionState() == ConnectionState.CLOSED) { + connectionsByRelay.remove(relayUri); + unreachableRelays.put(relayUri, "Connection closed"); + } + }); + } + + /** + * The relays that could not be reached when the pool was built. + * + * @return the unreachable relays' URIs + */ + public List getUnreachableRelays() { + return configuredRelayUris.stream().filter(unreachableRelays::containsKey).toList(); + } + + @Override + public void close() { + reconnectScheduler.shutdownNow(); + subscriptions.forEach(RelaySubscription::close); + subscriptions.clear(); + connectionsByRelay.values().forEach(this::closeQuietly); + connectionsByRelay.clear(); + } + + private void closeQuietly(RelayConnection connection) { + try { + connection.close(); + } catch (Exception e) { + log.warn("Failed to close relay {}: {}", connection.getRelayUri(), e.getMessage()); + } + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/RelayPublishOutcome.java b/nostr-java-client/src/main/java/nostr/client/relay/RelayPublishOutcome.java new file mode 100644 index 00000000..09ad7331 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/RelayPublishOutcome.java @@ -0,0 +1,102 @@ +package nostr.client.relay; + +import lombok.NonNull; + +import java.util.Optional; + +/** + * What one relay did with one published event. + * + *

Relays disagree, so a publish to several of them has no single answer: one accepts, another + * rejects because the author is banned, a third never replies. This type records that per relay + * so the caller can act on the difference, rather than collapsing it into a boolean. + * + * @param relayUri the relay this outcome came from + * @param status whether the relay accepted, rejected, or failed to answer + * @param reason the relay's verbatim explanation when it rejected, otherwise empty + */ +public record RelayPublishOutcome(String relayUri, Status status, String reason) { + + /** How a relay responded to a published event. */ + public enum Status { + /** The relay stored the event. */ + ACCEPTED, + /** The relay refused the event and said why. */ + REJECTED, + /** The relay did not answer before the pool's timeout expired. */ + TIMED_OUT, + /** The relay could not be reached at all. */ + UNREACHABLE + } + + public RelayPublishOutcome { + if (relayUri == null || relayUri.isBlank()) { + throw new IllegalArgumentException("relayUri must not be blank"); + } + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + reason = reason == null ? "" : reason; + } + + /** + * Record that a relay stored the event. + * + * @param relayUri the relay that accepted + * @return the accepted outcome + */ + public static RelayPublishOutcome accepted(@NonNull String relayUri) { + return new RelayPublishOutcome(relayUri, Status.ACCEPTED, ""); + } + + /** + * Record that a relay refused the event. + * + * @param relayUri the relay that rejected + * @param reason the relay's verbatim reason, such as {@code "blocked: pubkey banned"} + * @return the rejected outcome + */ + public static RelayPublishOutcome rejected(@NonNull String relayUri, String reason) { + return new RelayPublishOutcome(relayUri, Status.REJECTED, reason); + } + + /** + * Record that a relay did not answer in time. + * + * @param relayUri the relay that stayed silent + * @param reason what the transport reported + * @return the timed-out outcome + */ + public static RelayPublishOutcome timedOut(@NonNull String relayUri, String reason) { + return new RelayPublishOutcome(relayUri, Status.TIMED_OUT, reason); + } + + /** + * Record that a relay could not be reached. + * + * @param relayUri the relay that could not be reached + * @param reason what the transport reported + * @return the unreachable outcome + */ + public static RelayPublishOutcome unreachable(@NonNull String relayUri, String reason) { + return new RelayPublishOutcome(relayUri, Status.UNREACHABLE, reason); + } + + /** + * Whether this relay stored the event. + * + * @return {@code true} when the relay accepted + */ + public boolean isAccepted() { + return status == Status.ACCEPTED; + } + + /** + * The relay's explanation, when it gave one. + * + * @return the reason, empty for an accepted outcome + */ + public Optional findReason() { + return reason.isBlank() ? Optional.empty() : Optional.of(reason); + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/RelaySubscription.java b/nostr-java-client/src/main/java/nostr/client/relay/RelaySubscription.java new file mode 100644 index 00000000..cb17fba2 --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/RelaySubscription.java @@ -0,0 +1,222 @@ +package nostr.client.relay; + +import lombok.extern.slf4j.Slf4j; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.event.message.EventMessage; +import nostr.event.message.ReqMessage; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * One subscription spread across many relays, presented to the caller as a single stream. + * + *

The subscription keeps its filter rather than forgetting it once registered, because a + * relay that drops must be re-subscribed when it returns; a fire-and-forget handle could not do + * that. It also tracks which relays have finished replaying stored events, so the caller gets + * one end-of-backlog signal instead of one per relay. + */ +@Slf4j +public class RelaySubscription implements AutoCloseable { + + private final String subscriptionId; + private final List filters; + private final SubscriptionListener listener; + private final DeliveredEventWindow deliveredEvents; + private final Map handlesByRelay = new ConcurrentHashMap<>(); + private final Set relaysAwaitingBacklog = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean endOfStoredEventsAnnounced = new AtomicBoolean(); + + /** + * Serialises delivery so the caller sees frames in the order the relay sent them. + * + *

The transport dispatches each inbound payload on its own thread, so an {@code EOSE} can + * overtake the stored events it follows and tell the caller the backlog is drained while those + * events are still arriving. Handling one payload at a time restores the order the protocol + * defines, which is what makes the end-of-backlog signal mean anything. + */ + private final Object deliveryOrder = new Object(); + private final AtomicBoolean closed = new AtomicBoolean(); + + RelaySubscription( + String subscriptionId, + List filters, + SubscriptionListener listener, + int deduplicationWindowSize) { + this.subscriptionId = Objects.requireNonNull(subscriptionId, "subscriptionId"); + this.filters = List.copyOf(filters); + this.listener = Objects.requireNonNull(listener, "listener"); + this.deliveredEvents = new DeliveredEventWindow(deduplicationWindowSize); + } + + /** + * The identifier relays see for this subscription. + * + * @return the subscription identifier + */ + public String getSubscriptionId() { + return subscriptionId; + } + + /** + * The relays currently feeding this subscription. + * + * @return the subscribed relays' URIs + */ + public Set getSubscribedRelays() { + return Set.copyOf(handlesByRelay.keySet()); + } + + /** + * Whether the caller has been told the stored backlog is drained. + * + * @return {@code true} once the single end-of-stored-events signal has fired + */ + public boolean hasAnnouncedEndOfStoredEvents() { + return endOfStoredEventsAnnounced.get(); + } + + /** + * Register this subscription's filter with one relay. + * + *

Used both when the subscription opens and when a relay rejoins, which is why the filter + * is retained rather than consumed. + * + * @param connection the relay to subscribe on + * @throws IOException if the relay refused the subscription + */ + void subscribeOn(RelayConnection connection) throws IOException { + if (closed.get()) { + return; + } + String relayUri = connection.getRelayUri(); + relaysAwaitingBacklog.add(relayUri); + AutoCloseable handle = + connection.subscribe( + new ReqMessage(subscriptionId, filters), + payload -> acceptPayload(relayUri, payload), + failure -> reportRelayFailure(relayUri, failure), + () -> reportRelayFailure(relayUri, new IOException("Relay " + relayUri + " closed"))); + handlesByRelay.put(relayUri, handle); + } + + /** + * Whether this subscription has been closed. + * + * @return {@code true} once closed, so the pool can forget it + */ + boolean isClosed() { + return closed.get(); + } + + /** + * Whether this relay is absent from the subscription and should be re-subscribed. + * + * @param relayUri the relay to check + * @return {@code true} when the relay is not currently feeding this subscription + */ + boolean isMissing(String relayUri) { + return !closed.get() && !handlesByRelay.containsKey(relayUri); + } + + private void acceptPayload(String relayUri, String payload) { + if (closed.get() || payload == null) { + return; + } + synchronized (deliveryOrder) { + if (payload.startsWith("[\"EOSE\"")) { + recordBacklogDrained(relayUri); + return; + } + if (payload.startsWith("[\"EVENT\"")) { + deliverIfNotAlreadySeen(relayUri, payload); + } + } + } + + /** + * Parse an event and pass it on, unless another relay already delivered it. + * + *

A malformed payload is reported and discarded rather than propagated, so one relay + * sending nonsense cannot end a subscription that the other relays are still serving. + */ + private void deliverIfNotAlreadySeen(String relayUri, String payload) { + GenericEvent event; + try { + EventMessage message = EventMessage.decode(payload); + event = message.getEvent(); + } catch (RuntimeException e) { + log.warn("Discarding unreadable payload from relay {}: {}", relayUri, e.getMessage()); + listener.onRelayFailure(relayUri, e); + return; + } + if (event != null && deliveredEvents.isFirstSighting(event.getId())) { + listener.onEvent(event); + } + } + + private void recordBacklogDrained(String relayUri) { + relaysAwaitingBacklog.remove(relayUri); + announceEndOfStoredEventsIfComplete(); + } + + /** + * Emit the one end-of-backlog signal once no relay is still owed. + * + *

Also called when a relay is given up on, so a relay that never answers cannot leave the + * caller waiting on a signal that will never come. + */ + void announceEndOfStoredEventsIfComplete() { + if (relaysAwaitingBacklog.isEmpty() && endOfStoredEventsAnnounced.compareAndSet(false, true)) { + listener.onEndOfStoredEvents(); + } + } + + /** Stop waiting for relays that have not replayed their backlog in time. */ + void stopAwaitingBacklog() { + relaysAwaitingBacklog.clear(); + announceEndOfStoredEventsIfComplete(); + } + + private void reportRelayFailure(String relayUri, Throwable failure) { + if (closed.get()) { + return; + } + handlesByRelay.remove(relayUri); + relaysAwaitingBacklog.remove(relayUri); + listener.onRelayFailure(relayUri, failure); + announceEndOfStoredEventsIfComplete(); + } + + /** + * Stop this subscription, dropping its listener on every relay. + * + *

No {@code CLOSE} is sent to the relays. A connection serves one request at a time, so + * sending one would consume the slot the next subscription needs and leave that subscription + * receiving nothing. The relay stops streaming when the connection closes, and an unread + * subscription on a still-open connection is the lesser cost. + */ + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + handlesByRelay.values().forEach(this::closeQuietly); + handlesByRelay.clear(); + relaysAwaitingBacklog.clear(); + } + + private void closeQuietly(AutoCloseable handle) { + try { + handle.close(); + } catch (Exception e) { + log.warn("Failed to cancel a relay subscription: {}", e.getMessage()); + } + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/relay/SubscriptionListener.java b/nostr-java-client/src/main/java/nostr/client/relay/SubscriptionListener.java new file mode 100644 index 00000000..c8cd445c --- /dev/null +++ b/nostr-java-client/src/main/java/nostr/client/relay/SubscriptionListener.java @@ -0,0 +1,44 @@ +package nostr.client.relay; + +import nostr.event.impl.GenericEvent; + +/** + * Receives what a multi-relay subscription produces. + * + *

Fanning one subscription across many relays turns a stream of raw frames into three + * distinct things a caller reacts to differently: matching events, the moment the stored backlog + * is drained, and trouble at an individual relay. Separating them here means a caller never has + * to inspect a payload to work out which it just received. + */ +public interface SubscriptionListener { + + /** + * A matching event, delivered once however many relays sent it. + * + * @param event the event, already parsed + */ + void onEvent(GenericEvent event); + + /** + * Every relay has finished replaying stored events, or stopped being waited for. + * + *

Fires exactly once per subscription. Events arriving after this are live rather than + * historical, which is what lets an application stop showing a loading state. + */ + default void onEndOfStoredEvents() { + // Callers interested only in events need not distinguish stored from live. + } + + /** + * One relay failed, while the subscription continues on the others. + * + *

This is the signal that a firehose is quietly degrading: without it, a subscription can + * fall from five relays to one over a day and look healthy throughout. + * + * @param relayUri the relay that failed + * @param failure what went wrong + */ + default void onRelayFailure(String relayUri, Throwable failure) { + // A caller that does not track relay health can ignore individual failures. + } +} diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java index cb346895..a4f80a11 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/NostrRelayClient.java @@ -2,6 +2,7 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; +import nostr.client.relay.RelayConnection; import nostr.event.BaseMessage; import nostr.event.message.ReqMessage; import org.springframework.beans.factory.annotation.Value; @@ -52,7 +53,8 @@ @Component @Scope(BeanDefinition.SCOPE_PROTOTYPE) @Slf4j -public class NostrRelayClient extends TextWebSocketHandler implements AutoCloseable { +public class NostrRelayClient extends TextWebSocketHandler + implements RelayConnection, AutoCloseable { private static final long DEFAULT_AWAIT_TIMEOUT_MS = 60000L; private static final long DEFAULT_MAX_IDLE_TIMEOUT_MS = 3600000L; private static final int DEFAULT_MAX_TEXT_MESSAGE_BUFFER_SIZE = 1048576; @@ -407,6 +409,12 @@ public static CompletableFuture connectAsync( RELAY_IO_EXECUTOR); } + @Override + public String getRelayUri() { + return relayUri; + } + + @Override public ConnectionState getConnectionState() { return connectionState.get(); } @@ -470,6 +478,7 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl } } + @Override @NostrRetryable public List send(T eventMessage) throws IOException { String json = eventMessage.encode(); @@ -575,6 +584,7 @@ public CompletableFuture> sendAsync(@NonNul return executeAsyncWithRetry(() -> send(eventMessage)); } + @Override @NostrRetryable public AutoCloseable subscribe( @NonNull T requestMessage, @@ -875,8 +885,7 @@ private void dispatchMessage(String payload) { && !targetSubscriptionId.equals(listener.subscriptionId())) { return; } - LISTENER_EXECUTOR.execute( - () -> safelyInvoke(listener.messageListener(), payload, listener)); + listener.frames().submit(() -> safelyInvoke(listener.messageListener(), payload, listener)); }); } @@ -1005,7 +1014,69 @@ private record ListenerRegistration( String subscriptionId, Consumer messageListener, Consumer errorListener, - Runnable closeListener) {} + Runnable closeListener, + FrameSequencer frames) { + + ListenerRegistration( + String subscriptionId, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) { + this(subscriptionId, messageListener, errorListener, closeListener, new FrameSequencer()); + } + } + + /** + * Delivers one listener's frames in the order the relay sent them. + * + *

Every inbound frame used to start its own virtual thread, so two frames could be handed + * to a listener in either order. That is unobservable for events alone but not for the signals + * that punctuate them: a relay sends its stored events and then {@code EOSE}, and a listener + * that sees {@code EOSE} first concludes the backlog is empty while events are still arriving. + * Any caller that ends a query on that signal then returns a partial answer, which looks + * exactly like a relay holding less data than it does. + * + *

Frames are queued per listener and drained by one thread at a time, so ordering is + * restored without serialising unrelated listeners against each other and without blocking the + * websocket's own receiving thread. + */ + private static final class FrameSequencer { + + private final java.util.Queue pending = new java.util.concurrent.ConcurrentLinkedQueue<>(); + private final java.util.concurrent.atomic.AtomicBoolean draining = + new java.util.concurrent.atomic.AtomicBoolean(); + + void submit(Runnable delivery) { + pending.add(delivery); + drainOnAnotherThread(); + } + + private void drainOnAnotherThread() { + if (draining.compareAndSet(false, true)) { + LISTENER_EXECUTOR.execute(this::drain); + } + } + + /** + * Drains until empty, then re-checks having released the flag. + * + *

The re-check closes the window where a frame is queued between the last poll and the + * flag being cleared, which would otherwise leave it waiting for a frame that never comes. + */ + private void drain() { + try { + Runnable delivery; + while ((delivery = pending.poll()) != null) { + delivery.run(); + } + } finally { + draining.set(false); + if (!pending.isEmpty()) { + drainOnAnotherThread(); + } + } + } + } @FunctionalInterface private interface IoSupplier { diff --git a/nostr-java-client/src/test/java/nostr/client/relay/DeliveredEventWindowTest.java b/nostr-java-client/src/test/java/nostr/client/relay/DeliveredEventWindowTest.java new file mode 100644 index 00000000..51f10363 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/DeliveredEventWindowTest.java @@ -0,0 +1,58 @@ +package nostr.client.relay; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies the de-duplication window suppresses repeats without growing without limit. */ +class DeliveredEventWindowTest { + + // Verifies the first sighting of an identifier is reported as new and later ones are not, + // which is what suppresses the same event arriving from several relays. + @Test + void onlyTheFirstSightingOfAnIdentifierIsNew() { + DeliveredEventWindow window = new DeliveredEventWindow(8); + + assertTrue(window.isFirstSighting("event-a")); + assertFalse(window.isFirstSighting("event-a")); + assertTrue(window.isFirstSighting("event-b")); + } + + // Verifies the window never holds more identifiers than its capacity, so a long-lived + // subscription cannot grow its memory without limit. + @Test + void theWindowNeverExceedsItsCapacity() { + int capacity = 16; + DeliveredEventWindow window = new DeliveredEventWindow(capacity); + + for (int identifier = 0; identifier < capacity * 50; identifier++) { + window.isFirstSighting("event-" + identifier); + } + + assertEquals(capacity, window.size()); + } + + // Verifies an identifier pushed out of the window is treated as new again, which is the + // accepted cost of bounding memory and must be visible rather than surprising. + @Test + void anEvictedIdentifierIsSeenAsNewAgain() { + DeliveredEventWindow window = new DeliveredEventWindow(4); + window.isFirstSighting("oldest"); + + for (int identifier = 0; identifier < 8; identifier++) { + window.isFirstSighting("filler-" + identifier); + } + + assertTrue(window.isFirstSighting("oldest")); + } + + // Verifies a window must have room for at least one identifier, since a zero-sized window + // would silently disable de-duplication. + @Test + void aWindowMustHaveRoomForAtLeastOneIdentifier() { + assertThrows(IllegalArgumentException.class, () -> new DeliveredEventWindow(0)); + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/FakeRelay.java b/nostr-java-client/src/test/java/nostr/client/relay/FakeRelay.java new file mode 100644 index 00000000..7e6534cd --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/FakeRelay.java @@ -0,0 +1,514 @@ +package nostr.client.relay; + +import nostr.client.springwebsocket.ConnectionState; +import nostr.client.springwebsocket.RelayTimeoutException; +import nostr.event.BaseMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.message.EventMessage; +import nostr.event.message.ReqMessage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * A relay whose behaviour is scripted by the test, standing in for real WebSocket transport. + * + *

Coordinating several relays means reasoning about relays that disagree: one accepts, one + * rejects with a reason, one never answers, one drops halfway through a subscription. Those + * scenarios are impractical to arrange against live relays and awkward to express by mocking a + * transport session. This fake makes each of them a single call. + * + *

Scripted behaviour is chosen per instance at construction: + * + *

{@code
+ * FakeRelay accepting = FakeRelay.accepting("wss://relay.one");
+ * FakeRelay banned = FakeRelay.rejecting("wss://relay.two", "blocked: pubkey banned");
+ * FakeRelay silent = FakeRelay.silent("wss://relay.three");
+ * }
+ * + *

Inbound traffic is driven explicitly with {@link #emit(String)} and {@link #dropConnection()} + * so tests never depend on timing. + */ +public final class FakeRelay implements RelayConnection { + + /** Reported by a silent relay so tests can assert on the timeout without waiting for one. */ + private static final long SIMULATED_TIMEOUT_MS = 100L; + + /** An event id that is never the one under test, used to prove OKs are matched by id. */ + private static final String SOMEONE_ELSES_EVENT_ID = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + /** How the relay answers a {@link #send} call. */ + private enum SendBehaviour { + ACCEPT, + REJECT, + SILENT, + STALL, + ACKNOWLEDGE_OTHER_EVENT, + FAIL + } + + private final String relayUri; + private final SendBehaviour sendBehaviour; + private final String rejectionReason; + private final CyclicBarrier sendRendezvous; + private final Runnable duringSend; + + private final List sentMessages = new CopyOnWriteArrayList<>(); + private final List sentSubscriptionIds = new CopyOnWriteArrayList<>(); + private final Map subscribers = new ConcurrentHashMap<>(); + private final AtomicLong registrationSequence = new AtomicLong(); + private final CountDownLatch stallLatch = new CountDownLatch(1); + private final AtomicBoolean requestInFlight = new AtomicBoolean(false); + private final List storedEvents = new CopyOnWriteArrayList<>(); + private final AtomicBoolean replyToSubscriptions = new AtomicBoolean(false); + private final AtomicInteger peakConcurrentSends = new AtomicInteger(); + private volatile ConnectionState connectionState = ConnectionState.CONNECTED; + + private record Subscriber( + String subscriptionId, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) {} + + private FakeRelay(String relayUri, SendBehaviour sendBehaviour, String rejectionReason) { + this(relayUri, sendBehaviour, rejectionReason, null, null); + } + + private FakeRelay( + String relayUri, + SendBehaviour sendBehaviour, + String rejectionReason, + CyclicBarrier sendRendezvous) { + this(relayUri, sendBehaviour, rejectionReason, sendRendezvous, null); + } + + private FakeRelay( + String relayUri, + SendBehaviour sendBehaviour, + String rejectionReason, + CyclicBarrier sendRendezvous, + Runnable duringSend) { + this.duringSend = duringSend; + this.relayUri = relayUri; + this.sendBehaviour = sendBehaviour; + this.rejectionReason = rejectionReason; + this.sendRendezvous = sendRendezvous; + } + + /** + * A relay that accepts everything sent to it. + * + * @param relayUri the relay URI this fake answers to + * @return the scripted relay + */ + public static FakeRelay accepting(String relayUri) { + return new FakeRelay(relayUri, SendBehaviour.ACCEPT, null); + } + + /** + * A relay that rejects everything with a reason, as a relay enforcing a policy would. + * + * @param relayUri the relay URI this fake answers to + * @param reason the verbatim rejection reason, such as {@code "blocked: pubkey banned"} + * @return the scripted relay + */ + public static FakeRelay rejecting(String relayUri, String reason) { + return new FakeRelay(relayUri, SendBehaviour.REJECT, reason); + } + + /** + * A relay that accepts the request but never answers, so the caller times out. + * + *

Reports the timeout the same way real transport does, with a + * {@link RelayTimeoutException}, so callers can distinguish a slow relay from an unreachable + * one. The timeout is reported immediately rather than after a real delay, to keep tests fast + * and free of timing assumptions. + * + * @param relayUri the relay URI this fake answers to + * @return the scripted relay + */ + public static FakeRelay silent(String relayUri) { + return new FakeRelay(relayUri, SendBehaviour.SILENT, null); + } + + /** + * A relay that cannot be reached at all, failing every send. + * + * @param relayUri the relay URI this fake answers to + * @return the scripted relay + */ + public static FakeRelay unreachable(String relayUri) { + return new FakeRelay(relayUri, SendBehaviour.FAIL, null); + } + + /** + * A relay whose OK names a different event than the one just sent. + * + *

A connection can carry answers for several events, so a caller that takes the first OK it + * sees would credit someone else's acceptance to its own publish. This relay is how that + * mistake is caught. + * + * @param relayUri the relay URI this fake answers to + * @return the scripted relay + */ + /** + * A relay that blocks the caller instead of answering, exercising the pool's own timeout. + * + *

{@link #silent} reports a timeout immediately, which tests the reporting but never the + * waiting. A pool that failed to bound its wait would hang forever here, so this is the + * behaviour that proves the bound exists. Always {@link #release} it, so a failing test cannot + * leave a thread parked. + * + * @param relayUri the relay URI this fake answers to + * @return the scripted relay + */ + public static FakeRelay acknowledgingOtherEvents(String relayUri) { + return new FakeRelay(relayUri, SendBehaviour.ACKNOWLEDGE_OTHER_EVENT, null); + } + + public static FakeRelay stalling(String relayUri) { + return new FakeRelay(relayUri, SendBehaviour.STALL, null); + } + + /** + * Let any caller blocked by {@link #stalling} proceed. + * + *

Idempotent, so it is safe in a {@code finally} block whether or not anyone blocked. + */ + public void release() { + stallLatch.countDown(); + } + + /** + * A relay that accepts, but only once every relay sharing the barrier has also been reached. + * + *

This is how a test distinguishes concurrent fan-out from a sequential loop: a caller that + * publishes one relay at a time can never satisfy the barrier, so it blocks instead of + * passing. + * + * @param relayUri the relay URI this fake answers to + * @param sendRendezvous the barrier every participating relay must reach + * @return the scripted relay + */ + /** + * A relay that accepts, running the given action while the send is in progress. + * + *

The action observes the window in which this relay holds its connection, which is how a + * test measures whether sends to different relays overlap. + * + * @param relayUri the relay URI this fake answers to + * @param duringSend run while the send is in flight + * @return the scripted relay + */ + public static FakeRelay acceptingWhile(String relayUri, Runnable duringSend) { + return new FakeRelay(relayUri, SendBehaviour.ACCEPT, null, null, duringSend); + } + + public static FakeRelay acceptingAfter(String relayUri, CyclicBarrier sendRendezvous) { + return new FakeRelay(relayUri, SendBehaviour.ACCEPT, null, sendRendezvous); + } + + @Override + public String getRelayUri() { + return relayUri; + } + + @Override + public ConnectionState getConnectionState() { + return connectionState; + } + + @Override + public List send(T message) throws IOException { + requireOpen(); + requireNoRequestInFlight(); + try { + return sendWhileHoldingTheConnection(message); + } finally { + requestInFlight.set(false); + } + } + + /** + * Reject a second concurrent send, exactly as {@code NostrRelayClient} does. + * + *

The real client permits one request in flight per connection and throws + * {@link IllegalStateException} otherwise. A fake that quietly allowed concurrent sends would + * let a caller look correct in tests and fail against a real relay. + */ + private void requireNoRequestInFlight() { + if (!requestInFlight.compareAndSet(false, true)) { + peakConcurrentSends.accumulateAndGet(2, Math::max); + throw new IllegalStateException( + "A request is already in flight. Concurrent send() calls are not supported."); + } + peakConcurrentSends.accumulateAndGet(1, Math::max); + } + + /** + * How many sends were ever in flight at once, so a test can tell queuing from luck. + * + * @return the highest number of overlapping sends observed + */ + public int getPeakConcurrentSends() { + return peakConcurrentSends.get(); + } + + private List sendWhileHoldingTheConnection(T message) + throws IOException { + sentMessages.add(message); + if (duringSend != null) { + duringSend.run(); + } + awaitRendezvous(); + String eventId = message instanceof EventMessage eventMessage + ? eventMessage.getEvent().getId() + : "event-id"; + return switch (sendBehaviour) { + case ACCEPT -> List.of(okResponse(eventId, true, "")); + case ACKNOWLEDGE_OTHER_EVENT -> List.of(okResponse(SOMEONE_ELSES_EVENT_ID, true, "")); + case REJECT -> List.of(okResponse(eventId, false, rejectionReason)); + case SILENT -> throw new RelayTimeoutException(SIMULATED_TIMEOUT_MS); + case STALL -> throw awaitReleaseThenTimeOut(); + case FAIL -> throw new IOException("Cannot reach relay " + relayUri); + }; + } + + @Override + public AutoCloseable subscribe( + T requestMessage, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException { + requireOpen(); + if (sendBehaviour == SendBehaviour.FAIL) { + throw new IOException("Cannot reach relay " + relayUri); + } + String subscriptionId = + requestMessage instanceof ReqMessage req ? req.getSubscriptionId() : null; + if (subscriptionId != null) { + sentSubscriptionIds.add(subscriptionId); + } + String registrationId = String.valueOf(registrationSequence.getAndIncrement()); + subscribers.put( + registrationId, + new Subscriber(subscriptionId, messageListener, errorListener, closeListener)); + replayStoredEvents(subscriptionId); + return () -> subscribers.remove(registrationId); + } + + /** + * Replay whatever this relay was scripted to hold, then report the backlog drained. + * + *

Runs on the subscribing thread so a caller that blocks awaiting the backlog is satisfied + * by the time {@code subscribe} returns. + */ + private void replayStoredEvents(String subscriptionId) { + if (!replyToSubscriptions.get() || subscriptionId == null) { + return; + } + storedEvents.forEach(storedEvent -> emitEvent(subscriptionId, storedEvent)); + emitEndOfStoredEvents(subscriptionId); + } + + /** + * Deliver a payload to every active subscriber, as an inbound relay frame would. + * + * @param payload the raw payload to deliver + */ + public void emit(String payload) { + subscribers.values().forEach(subscriber -> subscriber.messageListener().accept(payload)); + } + + /** + * Deliver a payload only to subscribers of one subscription, as a relay routing by + * subscription identifier would. + * + * @param subscriptionId the subscription the payload belongs to + * @param payload the raw payload to deliver + */ + public void emitTo(String subscriptionId, String payload) { + subscribers.values().stream() + .filter(subscriber -> subscriptionId.equals(subscriber.subscriptionId())) + .forEach(subscriber -> subscriber.messageListener().accept(payload)); + } + + /** + * Deliver a sequence of payloads to every active subscriber, in order. + * + *

Scripting a whole backlog in one call is how tests set up stored-event replay, and how + * they arrange for the same event to arrive from several relays. + * + * @param payloads the raw payloads to deliver, in order + */ + public void emitAll(List payloads) { + payloads.forEach(this::emit); + } + + /** + * Hold an event and replay it to any subscription that arrives, then end the backlog. + * + *

Callers that block until the backlog drains, such as a relay-list lookup, cannot emit + * events after subscribing because they never return control. Scripting the answer up front is + * how those callers are tested. + * + * @param storedEvent the event this relay holds + */ + public void answerNextSubscriptionWith(GenericEvent storedEvent) { + storedEvents.add(storedEvent); + replyToSubscriptions.set(true); + } + + /** + * Reply to any subscription with an immediately drained backlog and no events. + * + *

Models a relay that simply holds nothing matching the filter. + */ + public void emitEndOfStoredEventsForNextSubscription() { + replyToSubscriptions.set(true); + } + + /** + * Deliver an event frame for a subscription, as a relay replaying or streaming would. + * + * @param subscriptionId the subscription the event belongs to + * @param event the event to deliver + */ + public void emitEvent(String subscriptionId, GenericEvent event) { + emitTo(subscriptionId, new EventMessage(event, subscriptionId).encode()); + } + + /** + * Deliver an end-of-stored-events frame for a subscription. + * + *

Delaying or withholding this frame is what tests do to simulate a relay that never + * finishes replaying its backlog: simply never call it for that relay. + * + * @param subscriptionId the subscription the frame belongs to + */ + public void emitEndOfStoredEvents(String subscriptionId) { + emitTo(subscriptionId, "[\"EOSE\",\"" + subscriptionId + "\"]"); + } + + /** + * Drop the connection mid-stream, notifying subscribers as a relay disconnect would. + * + *

This is the failure that silently degrades a long-lived subscription, so tests covering + * recovery start here. + */ + public void dropConnection() { + connectionState = ConnectionState.CLOSED; + subscribers + .values() + .forEach( + subscriber -> { + subscriber.errorListener().accept(new IOException("Relay " + relayUri + " dropped")); + if (subscriber.closeListener() != null) { + subscriber.closeListener().run(); + } + }); + } + + /** + * A fresh connection to the same relay, as reconnecting through a factory would produce. + * + *

Recovery is modelled as a new connection rather than reviving this one, because that is + * the only option production has: {@code NostrRelayClient} offers no reopen path, so a closed + * connection is terminal and callers must obtain a replacement from a + * {@link RelayConnectionFactory}. Subscriptions do not carry over, which is precisely what + * makes re-subscription the caller's responsibility to prove. + * + * @return a new, connected relay with the same URI and scripted behaviour, and no subscribers + */ + public FakeRelay reconnected() { + return new FakeRelay(relayUri, sendBehaviour, rejectionReason, sendRendezvous, duringSend); + } + + /** + * Every message this relay was asked to send, in order. + * + * @return the messages received, so tests can assert what was published where + */ + public List getSentMessages() { + return List.copyOf(sentMessages); + } + + /** + * The subscription identifiers this relay was asked to open, in order. + * + * @return the subscription identifiers, so tests can assert re-subscription happened + */ + public List getSentSubscriptionIds() { + return List.copyOf(sentSubscriptionIds); + } + + /** + * How many subscribers are currently registered. + * + * @return the active subscriber count, so tests can assert handles were released + */ + public int getActiveSubscriberCount() { + return subscribers.size(); + } + + @Override + public void close() { + connectionState = ConnectionState.CLOSED; + List current = new ArrayList<>(subscribers.values()); + subscribers.clear(); + current.stream() + .map(Subscriber::closeListener) + .filter(Objects::nonNull) + .forEach(Runnable::run); + } + + private void awaitRendezvous() throws IOException { + if (sendRendezvous == null) { + return; + } + try { + sendRendezvous.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted awaiting concurrent sends on " + relayUri, e); + } catch (BrokenBarrierException | TimeoutException e) { + throw new IOException("Sends to " + relayUri + " were not concurrent", e); + } + } + + private RelayTimeoutException awaitReleaseThenTimeOut() throws IOException { + try { + stallLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while stalling on relay " + relayUri, e); + } + return new RelayTimeoutException(SIMULATED_TIMEOUT_MS); + } + + private void requireOpen() throws IOException { + if (connectionState == ConnectionState.CLOSED) { + throw new IOException("Relay " + relayUri + " is closed"); + } + } + + private String okResponse(String eventId, boolean accepted, String reason) { + return "[\"OK\",\"" + eventId + "\"," + accepted + ",\"" + reason + "\"]"; + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/FakeRelayTest.java b/nostr-java-client/src/test/java/nostr/client/relay/FakeRelayTest.java new file mode 100644 index 00000000..7267f995 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/FakeRelayTest.java @@ -0,0 +1,219 @@ +package nostr.client.relay; + +import nostr.client.springwebsocket.ConnectionState; +import nostr.client.springwebsocket.RelayTimeoutException; +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Proves the {@link FakeRelay} fixture can express the relay behaviours that coordinating code + * must handle, so later work can be tested without WebSockets, Docker, or mocking. + */ +class FakeRelayTest { + + // Verifies a subscriber receives multiple emitted payloads and stops after its handle closes, + // mirroring NostrRelayClientSubscriptionTest but without mocking a WebSocketSession. + @Test + void subscriberReceivesPayloadsUntilItsHandleIsClosed() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + List received = new ArrayList<>(); + AtomicBoolean errored = new AtomicBoolean(false); + + AutoCloseable handle = + relay.subscribe(new ReqMessage("sub"), received::add, error -> errored.set(true), null); + + relay.emit("event-one"); + relay.emit("event-two"); + + assertEquals(List.of("event-one", "event-two"), received); + assertFalse(errored.get()); + assertEquals(List.of("sub"), relay.getSentSubscriptionIds()); + + handle.close(); + relay.emit("event-three"); + + assertEquals(List.of("event-one", "event-two"), received); + assertEquals(0, relay.getActiveSubscriberCount()); + } + + // Verifies an accepting relay reports success while a rejecting relay returns its reason + // verbatim, which is what per-relay publish outcomes are built from. + @Test + void acceptingAndRejectingRelaysAreDistinguishable() throws Exception { + FakeRelay accepting = FakeRelay.accepting("wss://relay.one"); + FakeRelay rejecting = FakeRelay.rejecting("wss://relay.two", "blocked: pubkey banned"); + + assertTrue(accepting.send(new ReqMessage("sub")).getFirst().contains("true")); + + String rejection = rejecting.send(new ReqMessage("sub")).getFirst(); + assertTrue(rejection.contains("false")); + assertTrue(rejection.contains("blocked: pubkey banned")); + } + + // Verifies a silent relay reports a relay timeout, the same typed failure real transport + // raises, so callers can tell a slow relay from an unreachable one. + @Test + void silentRelayTimesOutInsteadOfAnswering() { + FakeRelay relay = FakeRelay.silent("wss://relay.three"); + + assertThrows(RelayTimeoutException.class, () -> relay.send(new ReqMessage("sub"))); + } + + // Verifies an unreachable relay fails both sending and subscribing, as a down relay would. + @Test + void unreachableRelayRefusesSendAndSubscribe() { + FakeRelay relay = FakeRelay.unreachable("wss://relay.four"); + + assertThrows(IOException.class, () -> relay.send(new ReqMessage("sub"))); + assertThrows( + IOException.class, + () -> relay.subscribe(new ReqMessage("sub"), payload -> {}, error -> {}, null)); + } + + // Verifies dropping a connection mid-stream notifies subscribers and marks the relay closed, + // which is the silent-degradation failure that recovery work must detect. + @Test + void droppingConnectionNotifiesSubscribersAndClosesTheRelay() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + AtomicInteger errors = new AtomicInteger(); + AtomicBoolean closed = new AtomicBoolean(false); + + relay.subscribe( + new ReqMessage("sub"), payload -> {}, error -> errors.incrementAndGet(), () -> closed.set(true)); + + relay.dropConnection(); + + assertEquals(1, errors.get()); + assertTrue(closed.get()); + assertEquals(ConnectionState.CLOSED, relay.getConnectionState()); + assertThrows(IOException.class, () -> relay.send(new ReqMessage("sub"))); + } + + // Verifies recovery yields a fresh connection carrying no subscriptions, matching production + // where a closed NostrRelayClient cannot be reopened and must be replaced via the factory. + @Test + void reconnectingYieldsAFreshConnectionWithNoSubscriptions() throws Exception { + FakeRelay dropped = FakeRelay.accepting("wss://relay.one"); + dropped.subscribe(new ReqMessage("sub"), payload -> {}, error -> {}, null); + dropped.dropConnection(); + + FakeRelay recovered = dropped.reconnected(); + + assertEquals(ConnectionState.CLOSED, dropped.getConnectionState()); + assertEquals(ConnectionState.CONNECTED, recovered.getConnectionState()); + assertEquals(dropped.getRelayUri(), recovered.getRelayUri()); + assertEquals(0, recovered.getActiveSubscriberCount()); + assertEquals(1, recovered.send(new ReqMessage("sub")).size()); + } + + // Verifies the relay records what it was asked to send, so tests can assert which events + // reached which relay. + @Test + void relayRecordsTheMessagesItWasSent() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + + relay.send(new ReqMessage("first")); + relay.send(new ReqMessage("second")); + + assertEquals(2, relay.getSentMessages().size()); + } + + // Verifies an end-of-stored-events frame can be emitted for a subscription, and that + // withholding it simply means never calling it, which is how a stalled relay is simulated. + @Test + void endOfStoredEventsCanBeEmittedOrWithheld() throws Exception { + FakeRelay replaying = FakeRelay.accepting("wss://relay.one"); + FakeRelay stalled = FakeRelay.accepting("wss://relay.two"); + List fromReplaying = new ArrayList<>(); + List fromStalled = new ArrayList<>(); + + replaying.subscribe(new ReqMessage("sub"), fromReplaying::add, error -> {}, null); + stalled.subscribe(new ReqMessage("sub"), fromStalled::add, error -> {}, null); + + replaying.emit("stored-event"); + replaying.emitEndOfStoredEvents("sub"); + stalled.emit("stored-event"); + + assertEquals(List.of("stored-event", "[\"EOSE\",\"sub\"]"), fromReplaying); + assertEquals(List.of("stored-event"), fromStalled); + } + + // Verifies a subscriber that outlives an earlier released one keeps receiving events, guarding + // against registration identifiers being reused and silently evicting a live subscriber. + @Test + void releasingOneSubscriberDoesNotEvictThoseThatRemain() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + List released = new ArrayList<>(); + List surviving = new ArrayList<>(); + List latest = new ArrayList<>(); + + AutoCloseable releasedHandle = + relay.subscribe(new ReqMessage("released"), released::add, error -> {}, null); + relay.subscribe(new ReqMessage("surviving"), surviving::add, error -> {}, null); + releasedHandle.close(); + relay.subscribe(new ReqMessage("latest"), latest::add, error -> {}, null); + + relay.emit("event"); + + assertTrue(released.isEmpty()); + assertEquals(List.of("event"), surviving, "a live subscriber was evicted by a reused id"); + assertEquals(List.of("event"), latest); + assertEquals(2, relay.getActiveSubscriberCount()); + } + + // Verifies payloads addressed to one subscription reach only that subscription's listener, + // mirroring NostrRelayClientSubscriptionRoutingTest without mocking a WebSocketSession. + @Test + void payloadsAreRoutedToTheirOwnSubscription() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + List seenByA = new ArrayList<>(); + List seenByB = new ArrayList<>(); + + relay.subscribe(new ReqMessage("sub-a"), seenByA::add, error -> {}, null); + relay.subscribe(new ReqMessage("sub-b"), seenByB::add, error -> {}, null); + + relay.emitTo("sub-a", "for-a"); + relay.emitTo("sub-b", "for-b"); + + assertEquals(List.of("for-a"), seenByA); + assertEquals(List.of("for-b"), seenByB); + } + + // Verifies a scripted backlog is delivered in order, which is how stored-event replay is + // arranged before an end-of-stored-events frame. + @Test + void scriptedEventSequenceIsDeliveredInOrder() throws Exception { + FakeRelay relay = FakeRelay.accepting("wss://relay.one"); + List received = new ArrayList<>(); + + relay.subscribe(new ReqMessage("sub"), received::add, error -> {}, null); + relay.emitAll(List.of("one", "two", "three")); + + assertEquals(List.of("one", "two", "three"), received); + } + + // Verifies a factory can hand out scripted relays by URI, which is how coordinating code + // receives fakes in place of real connections. + @Test + void factorySuppliesScriptedRelaysByUri() throws Exception { + FakeRelay accepting = FakeRelay.accepting("wss://relay.one"); + FakeRelay rejecting = FakeRelay.rejecting("wss://relay.two", "rate-limited"); + RelayConnectionFactory factory = + relayUri -> "wss://relay.one".equals(relayUri) ? accepting : rejecting; + + assertEquals("wss://relay.one", factory.connect("wss://relay.one").getRelayUri()); + assertTrue(factory.connect("wss://relay.two").send(new ReqMessage("s")) + .getFirst().contains("rate-limited")); + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolConcurrencyTest.java b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolConcurrencyTest.java new file mode 100644 index 00000000..6d85ea10 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolConcurrencyTest.java @@ -0,0 +1,302 @@ +package nostr.client.relay; + +import nostr.base.PublicKey; +import nostr.client.springwebsocket.ConnectionState; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the pool respects each relay's one-request-in-flight limit without giving up + * concurrency across relays, and that a relay which recovers rejoins on its own. + */ +class RelayPoolConcurrencyTest { + + private static final String FIRST_RELAY = "wss://relay.one"; + private static final String SECOND_RELAY = "wss://relay.two"; + private static final int CONCURRENT_PUBLISHES = 8; + private static final int RACE_ROUNDS = 60; + private static final int RETRIES_PER_ROUND = 20; + + // Verifies many threads publishing to the same relay at once all succeed: a connection accepts + // one request at a time, so the pool must queue them rather than let them collide. + @Test + void concurrentPublishesToOneRelayAreQueuedRatherThanRejected() throws Exception { + FakeRelay relay = FakeRelay.accepting(FIRST_RELAY); + + try (RelayPool pool = new RelayPool(List.of(FIRST_RELAY), relayUri -> relay); + ExecutorService callers = Executors.newFixedThreadPool(CONCURRENT_PUBLISHES)) { + + CountDownLatch startTogether = new CountDownLatch(1); + List> published = new ArrayList<>(); + for (int publish = 0; publish < CONCURRENT_PUBLISHES; publish++) { + published.add( + callers.submit( + () -> { + startTogether.await(); + return pool.publish(signedEvent()); + })); + } + startTogether.countDown(); + + for (Future outcome : published) { + assertEquals(List.of(FIRST_RELAY), outcome.get(10, TimeUnit.SECONDS).getAcceptingRelays()); + } + assertEquals( + 1, relay.getPeakConcurrentSends(), "the relay saw overlapping sends, so it was not queued"); + } + } + + // Verifies queuing per relay does not serialise the whole pool: two relays must still be + // published to at the same time, which only holds if each relay has its own queue. + @Test + void publishesToDifferentRelaysStillOverlap() throws Exception { + AtomicInteger sendsInFlight = new AtomicInteger(); + AtomicInteger peakInFlight = new AtomicInteger(); + CountDownLatch bothArrived = new CountDownLatch(2); + RelayConnectionFactory factory = + relayUri -> + FakeRelay.acceptingWhile( + relayUri, + () -> { + peakInFlight.accumulateAndGet(sendsInFlight.incrementAndGet(), Math::max); + bothArrived.countDown(); + awaitQuietly(bothArrived); + sendsInFlight.decrementAndGet(); + }); + + try (RelayPool pool = new RelayPool(List.of(FIRST_RELAY, SECOND_RELAY), factory)) { + PublishResult result = pool.publish(signedEvent()); + + assertEquals(List.of(FIRST_RELAY, SECOND_RELAY), result.getAcceptingRelays()); + assertEquals(2, peakInFlight.get(), "relays were published to one after another"); + } + } + + // Verifies the pool reports each relay's connection state, so an operator can tell which + // relays are actually carrying traffic. + @Test + void perRelayConnectionStateIsObservable() throws Exception { + FakeRelay healthy = FakeRelay.accepting(FIRST_RELAY); + FakeRelay dropped = FakeRelay.accepting(SECOND_RELAY); + + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, SECOND_RELAY), + relayUri -> FIRST_RELAY.equals(relayUri) ? healthy : dropped)) { + + assertEquals(ConnectionState.CONNECTED, pool.getConnectionState(FIRST_RELAY).orElseThrow()); + + dropped.dropConnection(); + + assertEquals(ConnectionState.CLOSED, pool.getConnectionState(SECOND_RELAY).orElseThrow()); + assertEquals(List.of(FIRST_RELAY), pool.getConnectedRelays()); + assertTrue(pool.getConnectionState("wss://relay.never-configured").isEmpty()); + } + } + + // Verifies a relay that was unreachable at startup rejoins once it recovers, so a restart is + // not needed to pick it back up. + @Test + void aRelayThatRecoversRejoinsWithoutARestart() throws Exception { + AtomicInteger connectionAttempts = new AtomicInteger(); + RelayConnectionFactory intermittent = + relayUri -> { + if (SECOND_RELAY.equals(relayUri) && connectionAttempts.incrementAndGet() == 1) { + throw new IOException("connection refused"); + } + return FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = new RelayPool(List.of(FIRST_RELAY, SECOND_RELAY), intermittent)) { + assertEquals(List.of(SECOND_RELAY), pool.getUnreachableRelays()); + assertFalse(pool.publish(signedEvent()).getAcceptingRelays().contains(SECOND_RELAY)); + + pool.retryUnreachableRelays(); + + assertEquals(List.of(), pool.getUnreachableRelays()); + assertEquals( + List.of(FIRST_RELAY, SECOND_RELAY), pool.publish(signedEvent()).getAcceptingRelays()); + } + } + + // Verifies a relay that is still down after a retry stays marked down rather than being + // wrongly returned to service. + @Test + void aRelayThatIsStillDownStaysMarkedDown() throws Exception { + RelayConnectionFactory alwaysFailing = + relayUri -> { + if (SECOND_RELAY.equals(relayUri)) { + throw new IOException("connection refused"); + } + return FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = new RelayPool(List.of(FIRST_RELAY, SECOND_RELAY), alwaysFailing)) { + pool.retryUnreachableRelays(); + + assertEquals(List.of(SECOND_RELAY), pool.getUnreachableRelays()); + assertEquals(List.of(FIRST_RELAY), pool.getConnectedRelays()); + } + } + + // Verifies retrying downed relays while publishes are in flight does not corrupt the pool: + // membership changes from another thread must not disturb a publish already iterating it. + @Test + void retryingDownedRelaysDuringPublishesIsSafe() throws Exception { + AtomicReference failure = new AtomicReference<>(); + RelayConnectionFactory intermittent = + relayUri -> { + if (relayUri.contains("flaky") && ThreadLocalRandom.current().nextBoolean()) { + throw new IOException("connection refused"); + } + return FakeRelay.accepting(relayUri); + }; + + for (int round = 0; round < RACE_ROUNDS && failure.get() == null; round++) { + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, "wss://relay.flaky-one", "wss://relay.flaky-two"), + intermittent); + ExecutorService callers = Executors.newFixedThreadPool(3)) { + + CountDownLatch startTogether = new CountDownLatch(1); + for (int publisher = 0; publisher < 2; publisher++) { + callers.submit(() -> publishQuietly(pool, startTogether, failure)); + } + callers.submit(() -> retryQuietly(pool, startTogether, failure)); + startTogether.countDown(); + callers.shutdown(); + assertTrue(callers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + assertNull(failure.get(), "publishing while relays rejoined corrupted the pool"); + } + + private void publishQuietly( + RelayPool pool, CountDownLatch startTogether, AtomicReference failure) { + try { + startTogether.await(); + pool.publish(signedEvent()); + } catch (NoRelayAcceptedException expected) { + // Every relay may legitimately be down in a given round. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable e) { + failure.compareAndSet(null, e); + } + } + + private void retryQuietly( + RelayPool pool, CountDownLatch startTogether, AtomicReference failure) { + try { + startTogether.await(); + for (int attempt = 0; attempt < RETRIES_PER_ROUND; attempt++) { + pool.retryUnreachableRelays(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable e) { + failure.compareAndSet(null, e); + } + } + + // Verifies the pool retries downed relays on its own schedule, so an operator does not have to + // remember to poll for recovery. + @Test + void downedRelaysAreRetriedInTheBackground() throws Exception { + AtomicInteger connectionAttempts = new AtomicInteger(); + RelayConnectionFactory recoveringOnSecondAttempt = + relayUri -> { + if (SECOND_RELAY.equals(relayUri) && connectionAttempts.incrementAndGet() == 1) { + throw new IOException("connection refused"); + } + return FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, SECOND_RELAY), + recoveringOnSecondAttempt, + RelayPool.DEFAULT_PUBLISH_TIMEOUT, + Duration.ofMillis(50))) { + + assertEquals(List.of(SECOND_RELAY), pool.getUnreachableRelays()); + + await().atMost(5, TimeUnit.SECONDS) + .until(() -> pool.getConnectedRelays().contains(SECOND_RELAY)); + + assertEquals(List.of(), pool.getUnreachableRelays()); + } + } + + // Verifies a relay that drops after connecting is reconnected too, not just one that failed at + // startup, so a long-running pool does not quietly shrink. + @Test + void aRelayThatDropsAfterConnectingIsReconnected() throws Exception { + FakeRelay initial = FakeRelay.accepting(SECOND_RELAY); + AtomicInteger connectionAttempts = new AtomicInteger(); + RelayConnectionFactory factory = + relayUri -> { + if (!SECOND_RELAY.equals(relayUri)) { + return FakeRelay.accepting(relayUri); + } + return connectionAttempts.incrementAndGet() == 1 ? initial : FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, SECOND_RELAY), + factory, + RelayPool.DEFAULT_PUBLISH_TIMEOUT, + Duration.ofMillis(50))) { + + assertEquals(List.of(FIRST_RELAY, SECOND_RELAY), pool.getConnectedRelays()); + + initial.dropConnection(); + + await().atMost(5, TimeUnit.SECONDS) + .until(() -> pool.getConnectionState(SECOND_RELAY).orElseThrow() == ConnectionState.CONNECTED); + assertEquals( + List.of(FIRST_RELAY, SECOND_RELAY), pool.publish(signedEvent()).getAcceptingRelays()); + } + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private GenericEvent signedEvent() { + GenericEvent event = + GenericEvent.builder() + .pubKey(new PublicKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + .kind(1) + .content("hello") + .build(); + event.update(); + return event; + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolMembershipTest.java b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolMembershipTest.java new file mode 100644 index 00000000..ba1976e5 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolMembershipTest.java @@ -0,0 +1,152 @@ +package nostr.client.relay; + +import nostr.base.PublicKey; +import nostr.client.springwebsocket.ConnectionState; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies relays can join and leave a running pool, and that a relay borrowed for one operation + * is released without cutting off another caller still using it. + */ +class RelayPoolMembershipTest { + + private static final String CONFIGURED_RELAY = "wss://relay.configured"; + private static final String BORROWED_RELAY = "wss://relay.borrowed"; + private static final List TEXT_NOTES = + List.of(EventFilter.builder().kind(1).build()); + + private final Map openedRelays = new ConcurrentHashMap<>(); + + // Verifies a relay added at runtime immediately takes part in publishing, so a relay set can + // follow user preferences without restarting the application. + @Test + void aRelayAddedAtRuntimeParticipatesImmediately() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY)) { + assertEquals(List.of(CONFIGURED_RELAY), pool.publish(event()).getAcceptingRelays()); + + assertTrue(pool.addRelay(BORROWED_RELAY)); + + assertEquals( + List.of(CONFIGURED_RELAY, BORROWED_RELAY), pool.publish(event()).getAcceptingRelays()); + } + } + + // Verifies removing a relay stops it being published to and closes its connection. + @Test + void aRemovedRelayIsClosedAndNoLongerPublishedTo() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY, BORROWED_RELAY)) { + assertTrue(pool.removeRelay(BORROWED_RELAY)); + + assertEquals(List.of(CONFIGURED_RELAY), pool.publish(event()).getAcceptingRelays()); + assertEquals(List.of(CONFIGURED_RELAY), pool.getRelays()); + assertEquals( + ConnectionState.CLOSED, + openedRelays.get(BORROWED_RELAY).getConnectionState()); + } + } + + // Verifies adding a relay already in the pool does not open a second connection to it, since + // relays penalise clients that hold many sockets. + @Test + void addingAnExistingRelayDoesNotOpenASecondConnection() throws Exception { + AtomicInteger connectionsOpened = new AtomicInteger(); + RelayConnectionFactory counting = + relayUri -> { + connectionsOpened.incrementAndGet(); + return FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = new RelayPool(List.of(CONFIGURED_RELAY), counting)) { + pool.addRelay(CONFIGURED_RELAY); + pool.addRelay(CONFIGURED_RELAY); + + assertEquals(1, connectionsOpened.get()); + assertEquals(List.of(CONFIGURED_RELAY), pool.getRelays()); + } + } + + // Verifies a relay borrowed for one operation is closed when released, so connections opened + // for a single delivery do not accumulate over a long-running process. + @Test + void aBorrowedRelayIsClosedWhenReleased() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY)) { + pool.addRelay(BORROWED_RELAY); + + assertTrue(pool.releaseRelay(BORROWED_RELAY)); + + assertEquals(List.of(CONFIGURED_RELAY), pool.getRelays()); + assertEquals( + ConnectionState.CLOSED, + openedRelays.get(BORROWED_RELAY).getConnectionState()); + } + } + + // Verifies a relay borrowed twice survives the first release, so two overlapping deliveries to + // the same recipient do not cut each other off. + @Test + void aRelayBorrowedTwiceSurvivesTheFirstRelease() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY)) { + pool.addRelay(BORROWED_RELAY); + pool.addRelay(BORROWED_RELAY); + + assertFalse(pool.releaseRelay(BORROWED_RELAY), "the relay closed while still in use"); + assertEquals( + List.of(CONFIGURED_RELAY, BORROWED_RELAY), pool.publish(event()).getAcceptingRelays()); + + assertTrue(pool.releaseRelay(BORROWED_RELAY)); + assertEquals(List.of(CONFIGURED_RELAY), pool.getRelays()); + } + } + + // Verifies a relay joining a running pool is subscribed to existing subscriptions, so it + // starts contributing events rather than sitting idle. + @Test + void aRelayAddedDuringASubscriptionStartsContributing() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, receivedEvent -> {})) { + + pool.addRelay(BORROWED_RELAY); + + assertTrue(subscription.getSubscribedRelays().contains(BORROWED_RELAY)); + assertEquals( + List.of(subscription.getSubscriptionId()), + openedRelays.get(BORROWED_RELAY).getSentSubscriptionIds()); + } + } + + // Verifies removing a relay that was never a member reports that nothing was removed. + @Test + void removingAnAbsentRelayReportsNothingWasRemoved() throws Exception { + try (RelayPool pool = poolOf(CONFIGURED_RELAY)) { + assertFalse(pool.removeRelay("wss://relay.never-joined")); + } + } + + private RelayPool poolOf(String... relayUris) { + return new RelayPool( + List.of(relayUris), + relayUri -> openedRelays.computeIfAbsent(relayUri, FakeRelay::accepting)); + } + + private GenericEvent event() { + GenericEvent event = + GenericEvent.builder() + .pubKey(new PublicKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + .kind(1) + .content("hello") + .build(); + event.update(); + return event; + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolTest.java b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolTest.java new file mode 100644 index 00000000..7a71d638 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/RelayPoolTest.java @@ -0,0 +1,358 @@ +package nostr.client.relay; + +import nostr.base.PublicKey; +import nostr.client.springwebsocket.ConnectionState; +import nostr.event.BaseMessage; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CyclicBarrier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the pool publishes across relays and reports what each one did, including when they + * disagree. + */ +class RelayPoolTest { + + private static final String ACCEPTING_RELAY = "wss://relay.accepts"; + private static final String SECOND_ACCEPTING_RELAY = "wss://relay.also-accepts"; + private static final String THIRD_ACCEPTING_RELAY = "wss://relay.accepts-too"; + private static final String REJECTING_RELAY = "wss://relay.rejects"; + private static final String SILENT_RELAY = "wss://relay.silent"; + private static final String UNREACHABLE_RELAY = "wss://relay.down"; + private static final String BAN_REASON = "blocked: pubkey banned"; + private static final long UNINTERRUPTIBLE_SEND_MILLIS = 3_000L; + + // Verifies a publish to five disagreeing relays reports three acceptances, the rejecting + // relay's verbatim reason, and the silent relay as timed out, rather than one overall verdict. + @Test + void publishReportsWhatEachRelayDidWhenTheyDisagree() throws Exception { + try (RelayPool pool = poolOf( + FakeRelay.accepting(ACCEPTING_RELAY), + FakeRelay.accepting(SECOND_ACCEPTING_RELAY), + FakeRelay.accepting(THIRD_ACCEPTING_RELAY), + FakeRelay.rejecting(REJECTING_RELAY, BAN_REASON), + FakeRelay.silent(SILENT_RELAY))) { + + PublishResult result = pool.publish(signedEvent()); + + assertEquals( + List.of(ACCEPTING_RELAY, SECOND_ACCEPTING_RELAY, THIRD_ACCEPTING_RELAY), + result.getAcceptingRelays()); + assertEquals( + RelayPublishOutcome.Status.REJECTED, + result.findOutcome(REJECTING_RELAY).orElseThrow().status()); + assertEquals( + BAN_REASON, result.findOutcome(REJECTING_RELAY).orElseThrow().findReason().orElseThrow()); + assertEquals( + RelayPublishOutcome.Status.TIMED_OUT, + result.findOutcome(SILENT_RELAY).orElseThrow().status()); + assertTrue(result.isAccepted()); + assertFalse(result.isAcceptedByAllRelays()); + } + } + + // Verifies a publish that no relay accepted throws rather than returning, so an event that + // reached nobody cannot be mistaken for a published one. + @Test + void publishThrowsWhenNoRelayAcceptedTheEvent() throws Exception { + try (RelayPool pool = poolOf( + FakeRelay.rejecting(REJECTING_RELAY, BAN_REASON), FakeRelay.silent(SILENT_RELAY))) { + + NoRelayAcceptedException thrown = + assertThrows(NoRelayAcceptedException.class, () -> pool.publish(signedEvent())); + + assertEquals(2, thrown.getPublishResult().getFailures().size()); + assertTrue(thrown.getMessage().contains(BAN_REASON), "the relay's reason should survive"); + } + } + + // Verifies a single acceptance is enough for the publish to return, however many relays failed. + @Test + void publishReturnsWhenOneRelayAcceptedAmongFailures() throws Exception { + try (RelayPool pool = poolOf( + FakeRelay.rejecting(REJECTING_RELAY, BAN_REASON), + FakeRelay.accepting(ACCEPTING_RELAY), + FakeRelay.silent(SILENT_RELAY))) { + + PublishResult result = pool.publish(signedEvent()); + + assertEquals(List.of(ACCEPTING_RELAY), result.getAcceptingRelays()); + assertEquals(2, result.getFailures().size()); + } + } + + // Verifies the pool still starts and publishes when a configured relay cannot be reached, + // so one dead relay cannot stop an application booting. + @Test + void poolStartsAndPublishesDespiteAnUnreachableRelay() throws Exception { + RelayConnectionFactory factory = + relayUri -> { + if (UNREACHABLE_RELAY.equals(relayUri)) { + throw new IOException("connection refused"); + } + return FakeRelay.accepting(relayUri); + }; + + try (RelayPool pool = + new RelayPool(List.of(ACCEPTING_RELAY, UNREACHABLE_RELAY), factory)) { + + assertEquals(List.of(ACCEPTING_RELAY), pool.getConnectedRelays()); + assertEquals(List.of(UNREACHABLE_RELAY), pool.getUnreachableRelays()); + + PublishResult result = pool.publish(signedEvent()); + + assertEquals(List.of(ACCEPTING_RELAY), result.getAcceptingRelays()); + assertEquals( + RelayPublishOutcome.Status.UNREACHABLE, + result.findOutcome(UNREACHABLE_RELAY).orElseThrow().status()); + } + } + + // Verifies every configured relay receives the event, so fan-out reaches the whole pool. + @Test + void everyConnectedRelayReceivesTheEvent() throws Exception { + FakeRelay first = FakeRelay.accepting(ACCEPTING_RELAY); + FakeRelay second = FakeRelay.accepting(SECOND_ACCEPTING_RELAY); + + try (RelayPool pool = poolOf(first, second)) { + pool.publish(signedEvent()); + } + + assertEquals(1, first.getSentMessages().size()); + assertEquals(1, second.getSentMessages().size()); + } + + // Verifies a relay that never answers is bounded by the pool's timeout rather than hanging + // the publish, and is reported as timed out. + @Test + void aSilentRelayIsBoundedByTheConfiguredTimeout() throws Exception { + FakeRelay stalling = FakeRelay.stalling(SILENT_RELAY); + RelayConnectionFactory factory = + relayUri -> + SILENT_RELAY.equals(relayUri) ? stalling : FakeRelay.accepting(relayUri); + + try (RelayPool pool = + new RelayPool( + List.of(ACCEPTING_RELAY, SILENT_RELAY), factory, Duration.ofMillis(200))) { + + PublishResult result = pool.publish(signedEvent()); + + assertEquals(List.of(ACCEPTING_RELAY), result.getAcceptingRelays()); + assertEquals( + RelayPublishOutcome.Status.TIMED_OUT, + result.findOutcome(SILENT_RELAY).orElseThrow().status()); + } finally { + stalling.release(); + } + } + + // Verifies relays are published to concurrently rather than one after another: three relays + // that each block until all three have been reached can only complete if the sends overlap. + @Test + void relaysArePublishedToConcurrently() throws Exception { + CyclicBarrier allThreeReached = new CyclicBarrier(3); + List relayUris = + List.of(ACCEPTING_RELAY, SECOND_ACCEPTING_RELAY, THIRD_ACCEPTING_RELAY); + RelayConnectionFactory factory = + relayUri -> FakeRelay.acceptingAfter(relayUri, allThreeReached); + + try (RelayPool pool = new RelayPool(relayUris, factory, Duration.ofSeconds(5))) { + PublishResult result = pool.publish(signedEvent()); + + assertEquals(relayUris, result.getAcceptingRelays()); + } + } + + // Verifies the pool's timeout still bounds a publish when a relay ignores interruption, so one + // stuck transport cannot make the call outlast the timeout that exists to bound it. + @Test + void publishHonoursItsTimeoutEvenWhenARelayIgnoresInterruption() throws Exception { + RelayConnectionFactory factory = + relayUri -> + SILENT_RELAY.equals(relayUri) + ? new UninterruptibleRelay(relayUri) + : FakeRelay.accepting(relayUri); + + long startedAt = System.currentTimeMillis(); + try (RelayPool pool = + new RelayPool( + List.of(ACCEPTING_RELAY, SILENT_RELAY), factory, Duration.ofMillis(300))) { + + PublishResult result = pool.publish(signedEvent()); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals(List.of(ACCEPTING_RELAY), result.getAcceptingRelays()); + assertTrue( + elapsed < UNINTERRUPTIBLE_SEND_MILLIS, + "publish took " + elapsed + "ms, so the timeout did not bound the stuck relay"); + } + } + + /** A relay whose send ignores interruption, as a transport stuck in blocking I/O would. */ + private static final class UninterruptibleRelay implements RelayConnection { + private final String relayUri; + + private UninterruptibleRelay(String relayUri) { + this.relayUri = relayUri; + } + + @Override + public String getRelayUri() { + return relayUri; + } + + @Override + public ConnectionState getConnectionState() { + return ConnectionState.CONNECTED; + } + + @Override + public List send(T message) { + long until = System.currentTimeMillis() + UNINTERRUPTIBLE_SEND_MILLIS; + while (System.currentTimeMillis() < until) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + return List.of("[\"OK\",\"ignored\",true,\"\"]"); + } + + @Override + public AutoCloseable subscribe( + T requestMessage, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) { + return () -> {}; + } + + @Override + public void close() { + // Nothing to release: this relay holds no resources. + } + } + + // Verifies the timeout is a budget for the whole publish, not for each relay in turn: three + // stalled relays must not cost three times the wait the caller asked for. + @Test + void theTimeoutBoundsTheWholePublishNotEachRelaySeparately() throws Exception { + List stalling = new ArrayList<>(); + RelayConnectionFactory factory = + relayUri -> { + if (relayUri.contains("slow")) { + FakeRelay relay = FakeRelay.stalling(relayUri); + stalling.add(relay); + return relay; + } + return FakeRelay.accepting(relayUri); + }; + Duration timeout = Duration.ofMillis(300); + List stallingRelayUris = + List.of("wss://relay.slow-one", "wss://relay.slow-two", "wss://relay.slow-three", + "wss://relay.slow-four", "wss://relay.slow-five"); + List relayUris = new ArrayList<>(List.of(ACCEPTING_RELAY)); + relayUris.addAll(stallingRelayUris); + + long startedAt = System.currentTimeMillis(); + try (RelayPool pool = new RelayPool(relayUris, factory, timeout)) { + + PublishResult result = pool.publish(signedEvent()); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals(List.of(ACCEPTING_RELAY), result.getAcceptingRelays()); + // Spending the budget per relay would cost five times the timeout. The bound below leaves + // generous room for a loaded machine while staying far short of that, so the assertion + // discriminates between the two designs rather than measuring the build agent's speed. + long perRelayBudgetCost = timeout.toMillis() * stallingRelayUris.size(); + assertTrue( + elapsed < perRelayBudgetCost / 2, + "publish took " + elapsed + "ms for a " + timeout.toMillis() + + "ms budget across " + stallingRelayUris.size() + + " stalled relays, so the timeout is being spent per relay"); + } finally { + stalling.forEach(FakeRelay::release); + } + } + + // Verifies a relay that cannot be reached at publish time is reported as unreachable rather + // than as a timeout, so a down relay is distinguishable from a slow one. + @Test + void aRelayThatCannotBeReachedIsReportedAsUnreachableNotTimedOut() throws Exception { + try (RelayPool pool = + poolOf(FakeRelay.accepting(ACCEPTING_RELAY), FakeRelay.unreachable(UNREACHABLE_RELAY))) { + + PublishResult result = pool.publish(signedEvent()); + + assertEquals( + RelayPublishOutcome.Status.UNREACHABLE, + result.findOutcome(UNREACHABLE_RELAY).orElseThrow().status()); + } + } + + // Verifies an OK naming a different event is not credited to this publish, so one event's + // acceptance cannot be reported as another's. + @Test + void anOkForAnotherEventIsNotCreditedToThisPublish() throws Exception { + try (RelayPool pool = poolOf(FakeRelay.acknowledgingOtherEvents(ACCEPTING_RELAY))) { + + NoRelayAcceptedException thrown = + assertThrows(NoRelayAcceptedException.class, () -> pool.publish(signedEvent())); + + assertEquals( + RelayPublishOutcome.Status.TIMED_OUT, + thrown.getPublishResult().findOutcome(ACCEPTING_RELAY).orElseThrow().status()); + } + } + + // Verifies closing the pool closes every relay it opened. + @Test + void closingThePoolClosesEveryRelay() throws Exception { + FakeRelay first = FakeRelay.accepting(ACCEPTING_RELAY); + FakeRelay second = FakeRelay.accepting(SECOND_ACCEPTING_RELAY); + + poolOf(first, second).close(); + + assertEquals(ConnectionState.CLOSED, first.getConnectionState()); + assertEquals(ConnectionState.CLOSED, second.getConnectionState()); + } + + private RelayPool poolOf(FakeRelay... relays) { + Map byUri = + Arrays.stream(relays) + .collect( + Collectors.toMap( + FakeRelay::getRelayUri, + relay -> relay, + (first, duplicate) -> first, + LinkedHashMap::new)); + return new RelayPool(List.copyOf(byUri.keySet()), byUri::get); + } + + private GenericEvent signedEvent() { + GenericEvent event = + GenericEvent.builder() + .pubKey(new PublicKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + .kind(1) + .content("hello") + .build(); + event.update(); + return event; + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/relay/RelaySubscriptionTest.java b/nostr-java-client/src/test/java/nostr/client/relay/RelaySubscriptionTest.java new file mode 100644 index 00000000..ec898b29 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/relay/RelaySubscriptionTest.java @@ -0,0 +1,360 @@ +package nostr.client.relay; + +import nostr.base.PublicKey; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies one subscription spread across relays behaves as a single stream: events arrive once, + * the backlog ends once, and a relay that drops is noticed and recovered. + */ +class RelaySubscriptionTest { + + private static final String FIRST_RELAY = "wss://relay.one"; + private static final String SECOND_RELAY = "wss://relay.two"; + private static final String THIRD_RELAY = "wss://relay.three"; + private static final String FOURTH_RELAY = "wss://relay.four"; + private static final List TEXT_NOTES = + List.of(EventFilter.builder().kind(1).build()); + + // Verifies subscribing once registers the filter with every relay in the pool. + @Test + void subscribingRegistersTheFilterWithEveryRelay() throws Exception { + Map relays = + relaysNamed(FIRST_RELAY, SECOND_RELAY, THIRD_RELAY, FOURTH_RELAY); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, event -> {})) { + + assertEquals(relays.keySet(), subscription.getSubscribedRelays()); + relays + .values() + .forEach(relay -> assertEquals(1, relay.getSentSubscriptionIds().size())); + } + } + + // Verifies an event held by four relays reaches the caller once, already parsed, rather than + // once per relay that happens to have it. + @Test + void anEventArrivingFromFourRelaysIsDeliveredOnce() throws Exception { + Map relays = + relaysNamed(FIRST_RELAY, SECOND_RELAY, THIRD_RELAY, FOURTH_RELAY); + List received = new ArrayList<>(); + GenericEvent shared = event("shared note"); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, received::add)) { + + relays.values().forEach(relay -> relay.emitEvent(subscription.getSubscriptionId(), shared)); + + assertEquals(1, received.size()); + assertEquals(shared.getId(), received.getFirst().getId()); + assertEquals("shared note", received.getFirst().getContent()); + } + } + + // Verifies distinct events all arrive, so de-duplication does not suppress genuine traffic. + @Test + void distinctEventsAreAllDelivered() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + List received = new ArrayList<>(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, received::add)) { + + relays.get(FIRST_RELAY).emitEvent(subscription.getSubscriptionId(), event("first")); + relays.get(SECOND_RELAY).emitEvent(subscription.getSubscriptionId(), event("second")); + + assertEquals(2, received.size()); + } + } + + // Verifies the de-duplication window stays bounded, so a long-lived firehose subscription does + // not grow its memory without limit. + @Test + void theDeduplicationWindowStaysBounded() throws Exception { + int windowSize = 16; + Map relays = relaysNamed(FIRST_RELAY); + AtomicInteger received = new AtomicInteger(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = + pool.subscribe(TEXT_NOTES, ignored -> received.incrementAndGet(), windowSize)) { + + for (int emitted = 0; emitted < windowSize * 10; emitted++) { + relays.get(FIRST_RELAY).emitEvent(subscription.getSubscriptionId(), event("note " + emitted)); + } + + assertEquals(windowSize * 10, received.get()); + } + } + + // Verifies closing the subscription stops delivery from every relay at once. + @Test + void closingTheSubscriptionStopsEveryRelay() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + List received = new ArrayList<>(); + + try (RelayPool pool = poolOf(relays)) { + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, received::add); + subscription.close(); + + relays + .values() + .forEach(relay -> relay.emitEvent(subscription.getSubscriptionId(), event("after close"))); + + assertTrue(received.isEmpty()); + relays.values().forEach(relay -> assertEquals(0, relay.getActiveSubscriberCount())); + } + } + + // Verifies the caller is told the backlog is drained exactly once, after every relay has + // reported, rather than once per relay. + @Test + void oneEndOfStoredEventsIsEmittedAfterEveryRelayReports() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY, THIRD_RELAY); + AtomicInteger backlogDrained = new AtomicInteger(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = + pool.subscribe(TEXT_NOTES, countingBacklog(backlogDrained))) { + + relays.get(FIRST_RELAY).emitEndOfStoredEvents(subscription.getSubscriptionId()); + relays.get(SECOND_RELAY).emitEndOfStoredEvents(subscription.getSubscriptionId()); + assertEquals(0, backlogDrained.get(), "the backlog ended before every relay had reported"); + + relays.get(THIRD_RELAY).emitEndOfStoredEvents(subscription.getSubscriptionId()); + + assertEquals(1, backlogDrained.get()); + } + } + + // Verifies per-relay EOSE frames are never handed to the caller as events. + @Test + void perRelayEndOfStoredEventsFramesAreNotDeliveredAsEvents() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + List received = new ArrayList<>(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, received::add)) { + + relays.values().forEach(relay -> relay.emitEndOfStoredEvents(subscription.getSubscriptionId())); + + assertTrue(received.isEmpty()); + } + } + + // Verifies a relay that never replays its backlog cannot withhold the signal forever, so an + // application is not left showing a loading state indefinitely. + @Test + void theBacklogSignalArrivesDespiteASilentRelay() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + AtomicInteger backlogDrained = new AtomicInteger(); + + try (RelayPool pool = + new RelayPool( + List.copyOf(relays.keySet()), + relays::get, + RelayPool.DEFAULT_PUBLISH_TIMEOUT, + RelayPool.DEFAULT_RECONNECT_INTERVAL, + Duration.ofMillis(200)); + RelaySubscription subscription = + pool.subscribe(TEXT_NOTES, countingBacklog(backlogDrained))) { + + relays.get(FIRST_RELAY).emitEndOfStoredEvents(subscription.getSubscriptionId()); + + await().atMost(5, TimeUnit.SECONDS).until(() -> backlogDrained.get() == 1); + assertTrue(subscription.hasAnnouncedEndOfStoredEvents()); + } + } + + // Verifies stored events reach the caller before the backlog is reported drained. + // + // This asserts the guarantee holds; it does not reliably reproduce its absence. Removing the + // ordering lock still leaves this green, because the race needs timing a fake cannot force. + // The behaviour was found, and is covered, against a live relay in NostrClientRoundTripIT. + @Test + void storedEventsArriveBeforeTheBacklogIsReportedDrained() throws Exception { + Map relays = relaysNamed(FIRST_RELAY); + List observed = new ArrayList<>(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = + pool.subscribe( + TEXT_NOTES, + new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + observed.add("event"); + } + + @Override + public void onEndOfStoredEvents() { + observed.add("backlog-drained"); + } + })) { + + FakeRelay relay = relays.get(FIRST_RELAY); + relay.emitEvent(subscription.getSubscriptionId(), event("stored one")); + relay.emitEvent(subscription.getSubscriptionId(), event("stored two")); + relay.emitEndOfStoredEvents(subscription.getSubscriptionId()); + + assertEquals(List.of("event", "event", "backlog-drained"), observed); + } + } + + // Verifies a relay dropping mid-stream tells the caller, so a subscription cannot quietly + // degrade from several relays to one. + @Test + void aRelayDroppingMidStreamNotifiesTheCaller() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + AtomicReference failedRelay = new AtomicReference<>(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = + pool.subscribe(TEXT_NOTES, reportingFailures(failedRelay))) { + + relays.get(SECOND_RELAY).dropConnection(); + + assertEquals(SECOND_RELAY, failedRelay.get()); + assertFalse(subscription.getSubscribedRelays().contains(SECOND_RELAY)); + } + } + + // Verifies a relay that drops is re-subscribed once it reconnects, so the stream repairs + // itself rather than permanently losing a relay. + @Test + void aDroppedRelayIsResubscribedWhenItReconnects() throws Exception { + FakeRelay initial = FakeRelay.accepting(SECOND_RELAY); + AtomicInteger connectionAttempts = new AtomicInteger(); + AtomicReference replacement = new AtomicReference<>(); + RelayConnectionFactory factory = + relayUri -> { + if (!SECOND_RELAY.equals(relayUri)) { + return FakeRelay.accepting(relayUri); + } + if (connectionAttempts.incrementAndGet() == 1) { + return initial; + } + replacement.set(FakeRelay.accepting(relayUri)); + return replacement.get(); + }; + + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, SECOND_RELAY), + factory, + RelayPool.DEFAULT_PUBLISH_TIMEOUT, + Duration.ofMillis(50)); + RelaySubscription subscription = pool.subscribe(TEXT_NOTES, event -> {})) { + + initial.dropConnection(); + + await() + .atMost(5, TimeUnit.SECONDS) + .until(() -> subscription.getSubscribedRelays().contains(SECOND_RELAY)); + assertEquals( + List.of(subscription.getSubscriptionId()), replacement.get().getSentSubscriptionIds()); + } + } + + // Verifies a malformed payload is reported but does not end the subscription, so one relay + // sending nonsense cannot stop the others being served. + @Test + void aMalformedPayloadIsReportedWithoutEndingTheStream() throws Exception { + Map relays = relaysNamed(FIRST_RELAY, SECOND_RELAY); + List received = new ArrayList<>(); + AtomicBoolean reported = new AtomicBoolean(); + + try (RelayPool pool = poolOf(relays); + RelaySubscription subscription = + pool.subscribe( + TEXT_NOTES, + new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + received.add(event); + } + + @Override + public void onRelayFailure(String relayUri, Throwable failure) { + reported.set(true); + } + })) { + + relays.get(FIRST_RELAY).emitTo(subscription.getSubscriptionId(), "[\"EVENT\",\"not-json\""); + relays.get(SECOND_RELAY).emitEvent(subscription.getSubscriptionId(), event("still flowing")); + + assertTrue(reported.get(), "the malformed payload was not reported"); + assertEquals(1, received.size(), "the stream stopped after a malformed payload"); + } + } + + private SubscriptionListener countingBacklog(AtomicInteger backlogDrained) { + return new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + // This test observes only the backlog signal. + } + + @Override + public void onEndOfStoredEvents() { + backlogDrained.incrementAndGet(); + } + }; + } + + private SubscriptionListener reportingFailures(AtomicReference failedRelay) { + return new SubscriptionListener() { + @Override + public void onEvent(GenericEvent event) { + // This test observes only relay failures. + } + + @Override + public void onRelayFailure(String relayUri, Throwable failure) { + failedRelay.set(relayUri); + } + }; + } + + private Map relaysNamed(String... relayUris) { + Map relays = new LinkedHashMap<>(); + for (String relayUri : relayUris) { + relays.put(relayUri, FakeRelay.accepting(relayUri)); + } + return relays; + } + + private RelayPool poolOf(Map relays) { + return new RelayPool(List.copyOf(relays.keySet()), relays::get); + } + + private GenericEvent event(String content) { + GenericEvent event = + GenericEvent.builder() + .pubKey(new PublicKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + .kind(1) + .content(content) + .build(); + event.update(); + return event; + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderTest.java new file mode 100644 index 00000000..5b9ecf85 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderTest.java @@ -0,0 +1,79 @@ +package nostr.client.springwebsocket; + +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A listener must see frames in the order the relay sent them. + * + *

Every inbound frame used to be dispatched on a freshly started virtual thread, so two frames + * could reach a listener in either order. That is invisible for events alone, but the protocol + * punctuates them: a relay replays its stored events and then sends {@code EOSE}. A listener that + * sees {@code EOSE} early concludes the backlog is drained while events are still arriving, and + * every caller ends its query on that signal, so the query returns a partial answer that is + * indistinguishable from the relay holding less data. + * + *

Observed against a real relay before the fix: asking for three stored events, the + * end-of-backlog signal arrived with only one or two delivered, varying run to run. + */ +class NostrRelayClientFrameOrderTest { + + private static final int EVENT_COUNT = 200; + + // Verifies frames arrive in the order they were received, so a signal that terminates a query + // cannot overtake the events it is meant to follow. + @Test + void framesReachAListenerInTheOrderTheRelaySentThem() throws Exception { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenReturn(true); + + try (NostrRelayClient client = new NostrRelayClient(session, 1_000)) { + List seen = new CopyOnWriteArrayList<>(); + client.subscribe(new ReqMessage("sub"), seen::add, throwable -> {}, null); + + List sent = new ArrayList<>(); + for (int index = 0; index < EVENT_COUNT; index++) { + sent.add("[\"EVENT\",\"sub\",{\"id\":\"" + index + "\",\"kind\":1,\"content\":\"e\"}]"); + } + sent.add("[\"EOSE\",\"sub\"]"); + sent.forEach(payload -> client.handleTextMessage(session, new TextMessage(payload))); + + await().atMost(5, TimeUnit.SECONDS).until(() -> seen.size() == sent.size()); + assertEquals(sent, List.copyOf(seen)); + } + } + + // Verifies the end-of-backlog signal is delivered last, which is the specific ordering every + // query depends on to know it has seen everything the relay stored. + @Test + void theEndOfStoredEventsSignalArrivesAfterTheEventsItFollows() throws Exception { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenReturn(true); + + try (NostrRelayClient client = new NostrRelayClient(session, 1_000)) { + List seen = new CopyOnWriteArrayList<>(); + client.subscribe(new ReqMessage("sub"), seen::add, throwable -> {}, null); + + for (int index = 0; index < EVENT_COUNT; index++) { + client.handleTextMessage( + session, + new TextMessage("[\"EVENT\",\"sub\",{\"id\":\"" + index + "\",\"kind\":1}]")); + } + client.handleTextMessage(session, new TextMessage("[\"EOSE\",\"sub\"]")); + + await().atMost(5, TimeUnit.SECONDS).until(() -> seen.size() == EVENT_COUNT + 1); + assertEquals(EVENT_COUNT, seen.indexOf("[\"EOSE\",\"sub\"]")); + } + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/RelayConnectionContractTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/RelayConnectionContractTest.java new file mode 100644 index 00000000..b1a0f4c2 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/RelayConnectionContractTest.java @@ -0,0 +1,92 @@ +package nostr.client.springwebsocket; + +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayConnection; +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Runs one scenario against both the real {@link NostrRelayClient} and the {@link FakeRelay}, + * asserting they agree. + * + *

A fake is only worth trusting where it behaves like the thing it replaces. Later work + * verifies multi-relay behaviour entirely against {@code FakeRelay}, so if the fake's routing + * diverged from the client's, those tests would pass while production failed. This test is the + * guard against that: both are driven through the same {@link RelayConnection} interface, and + * the same assertions are applied to each. + */ +class RelayConnectionContractTest { + + private static final String FIRST_SUBSCRIPTION = "sub-a"; + private static final String SECOND_SUBSCRIPTION = "sub-b"; + private static final String EVENT_FOR_FIRST = + "[\"EVENT\",\"" + FIRST_SUBSCRIPTION + "\",{\"id\":\"one\"}]"; + + // Verifies the real client and the fake agree that a payload naming one subscription reaches + // only that subscription's listener, so multi-relay tests written against the fake hold for + // the real transport too. + @Test + void realClientAndFakeAgreeOnSubscriptionRouting() throws Exception { + assertEquals( + routeThroughRealClient(), routeThroughFake(), "fake diverged from the real relay client"); + } + + private RoutingOutcome routeThroughRealClient() throws Exception { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenReturn(true); + + try (NostrRelayClient client = NostrRelayClient.forTestWithRawSession(session, 1_000)) { + RoutingOutcome outcome = subscribeToBothSubscriptions(client); + client.handleTextMessage(session, new TextMessage(EVENT_FOR_FIRST)); + return outcome; + } + } + + private RoutingOutcome routeThroughFake() throws Exception { + try (FakeRelay relay = FakeRelay.accepting("wss://relay.one")) { + RoutingOutcome outcome = subscribeToBothSubscriptions(relay); + relay.emitTo(FIRST_SUBSCRIPTION, EVENT_FOR_FIRST); + return outcome; + } + } + + private RoutingOutcome subscribeToBothSubscriptions(RelayConnection relay) throws Exception { + RoutingOutcome outcome = new RoutingOutcome(); + relay.subscribe( + new ReqMessage(FIRST_SUBSCRIPTION), outcome.seenByFirst::add, error -> {}, null); + relay.subscribe( + new ReqMessage(SECOND_SUBSCRIPTION), outcome.seenBySecond::add, error -> {}, null); + return outcome; + } + + /** What each subscription's listener observed, compared across implementations. */ + private static final class RoutingOutcome { + private final List seenByFirst = new ArrayList<>(); + private final List seenBySecond = new ArrayList<>(); + + @Override + public boolean equals(Object other) { + return other instanceof RoutingOutcome outcome + && seenByFirst.equals(outcome.seenByFirst) + && seenBySecond.equals(outcome.seenBySecond); + } + + @Override + public int hashCode() { + return seenByFirst.hashCode() * 31 + seenBySecond.hashCode(); + } + + @Override + public String toString() { + return "first=" + seenByFirst + " second=" + seenBySecond; + } + } +} diff --git a/nostr-java-client/src/test/java/nostr/client/testing/RelayStoresEventsWaitStrategy.java b/nostr-java-client/src/test/java/nostr/client/testing/RelayStoresEventsWaitStrategy.java new file mode 100644 index 00000000..dbb09e75 --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/testing/RelayStoresEventsWaitStrategy.java @@ -0,0 +1,78 @@ +package nostr.client.testing; + +import lombok.extern.slf4j.Slf4j; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.event.impl.GenericEvent; +import nostr.event.message.EventMessage; +import nostr.id.Identity; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; + +import java.time.Duration; +import java.util.List; + +/** + * Holds a relay container until it has actually stored an event. + * + *

Ordinary readiness checks are not enough for a Nostr relay. The port binds before database + * migration completes, and on some hardware a relay worker panics during startup after which the + * relay still accepts WebSocket connections but never answers. Either way a test's first publish + * hangs until it times out, and the resulting failure points at the client rather than the + * container. + * + *

So readiness is defined as the behaviour the tests actually depend on: publish a throwaway + * event and require the relay to acknowledge it. A relay that does that is ready by definition. + */ +@Slf4j +public final class RelayStoresEventsWaitStrategy extends AbstractWaitStrategy { + + private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(2); + private static final Duration PROBE_INTERVAL = Duration.ofMillis(250); + private static final int PROBE_KIND = 1; + + @Override + protected void waitUntilReady() { + String relayUri = + "ws://" + waitStrategyTarget.getHost() + ":" + waitStrategyTarget.getFirstMappedPort(); + long giveUpAt = System.currentTimeMillis() + startupTimeout.toMillis(); + + while (System.currentTimeMillis() < giveUpAt) { + if (acknowledgesAnEvent(relayUri)) { + return; + } + sleepBriefly(); + } + throw new IllegalStateException( + "Relay at " + relayUri + " never acknowledged an event within " + startupTimeout); + } + + private boolean acknowledgesAnEvent(String relayUri) { + try (NostrRelayClient client = new NostrRelayClient(relayUri, PROBE_TIMEOUT.toMillis())) { + List replies = client.send(new EventMessage(probeEvent())); + return replies.stream().anyMatch(reply -> reply.startsWith("[\"OK\"")); + } catch (Exception notReadyYet) { + log.debug("Relay {} is not ready: {}", relayUri, notReadyYet.getMessage()); + return false; + } + } + + private GenericEvent probeEvent() { + Identity prober = Identity.generateRandomIdentity(); + GenericEvent event = + GenericEvent.builder() + .pubKey(prober.getPublicKey()) + .kind(PROBE_KIND) + .content("readiness probe") + .build(); + prober.sign(event); + return event; + } + + private void sleepBriefly() { + try { + Thread.sleep(PROBE_INTERVAL.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for a relay", e); + } + } +} diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 0b2d45db..933b082e 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.3.1 ../pom.xml diff --git a/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java b/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java index 0a8be767..494127dd 100644 --- a/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java +++ b/nostr-java-core/src/main/java/nostr/crypto/nip04/EncryptedDirectMessage.java @@ -19,6 +19,19 @@ import java.util.Arrays; import java.util.Base64; +/** + * Encrypts direct messages according to NIP-04. + * + * @deprecated NIP-04 conceals only a message's text. The sender, the recipient, the exact time, + * and the number of messages exchanged all remain public on every relay that carries the + * event, so an observer learns who talks to whom and when. Prefer NIP-17 private direct + * messages, which hide all of it: see {@code nostr.encryption.Nip17DirectMessageService} + * and the guide at {@code docs/howto/private-direct-messages.md}. Retained for reading + * existing conversations and for interoperating with clients that send nothing else. + * @see NIP-04 + * @see NIP-17 + */ +@Deprecated(since = "2.1.0") public class EncryptedDirectMessage { public static String encrypt(@NonNull String message, byte[] senderPrivKey, byte[] rcptPubKey) diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 23f64618..960e0f5f 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.3.1 ../pom.xml diff --git a/nostr-java-event/src/main/java/nostr/base/Kinds.java b/nostr-java-event/src/main/java/nostr/base/Kinds.java index c16f411d..5cee1413 100644 --- a/nostr-java-event/src/main/java/nostr/base/Kinds.java +++ b/nostr-java-event/src/main/java/nostr/base/Kinds.java @@ -20,6 +20,24 @@ private Kinds() {} public static final int DELETION = 5; public static final int REPOST = 6; public static final int REACTION = 7; + /** + * Seal (NIP-59): wraps an encrypted rumor and is signed by its real author. + * + * @see NIP-59 + */ + public static final int SEAL = 13; + /** + * Chat message (NIP-17): the rumor kind carrying private direct message content. + * + * @see NIP-17 + */ + public static final int CHAT_MESSAGE = 14; + /** + * File message (NIP-17): a rumor kind carrying an encrypted file reference. + * + * @see NIP-17 + */ + public static final int FILE_MESSAGE = 15; public static final int REACTION_TO_WEBSITE = 17; public static final int CHANNEL_CREATE = 40; public static final int CHANNEL_METADATA = 41; @@ -27,6 +45,12 @@ private Kinds() {} public static final int HIDE_MESSAGE = 43; public static final int MUTE_USER = 44; public static final int OTS_EVENT = 1040; + /** + * Gift wrap (NIP-59): the outermost layer, signed by a single-use ephemeral key. + * + * @see NIP-59 + */ + public static final int GIFT_WRAP = 1059; public static final int REPORT = 1984; public static final int COINJOIN_POOL = 2022; public static final int RESERVED_CASHU_WALLET_TOKENS = 7_374; @@ -38,9 +62,21 @@ private Kinds() {} public static final int REPLACEABLE_EVENT = 10_000; public static final int PIN_LIST = 10_001; public static final int RELAY_LIST_METADATA = 10_002; + /** + * Direct message relay list (NIP-17): the relays on which a user receives private messages. + * + * @see NIP-17 + */ + public static final int DM_RELAY_LIST = 10_050; public static final int NUTZAP_INFORMATIONAL = 10_019; public static final int WALLET = 17_375; public static final int EPHEMERAL_EVENT = 20_000; + /** + * Ephemeral gift wrap (NIP-59): a gift wrap relays must not store. + * + * @see NIP-59 + */ + public static final int EPHEMERAL_GIFT_WRAP = 21_059; public static final int CLIENT_AUTH = 22_242; public static final int NOSTR_CONNECT = 24_133; public static final int ADDRESSABLE_EVENT = 30_000; diff --git a/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java b/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java new file mode 100644 index 00000000..2365cc41 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/ChatMessage.java @@ -0,0 +1,248 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.tag.GenericTag; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * A private chat message, as defined by NIP-17. + * + *

The recipients plus the sender define a conversation. Adding or removing a participant + * starts a different conversation with its own history, so the recipient list is not a delivery + * detail but part of the message's identity. + * + *

A chat message never travels on its own. It becomes an unsigned rumor, which is sealed and + * gift wrapped once for each participant before publication. + * + * @see NIP-17 + */ +public final class ChatMessage { + + private static final String RECIPIENT_TAG = "p"; + private static final String REPLY_TAG = "e"; + private static final String SUBJECT_TAG = "subject"; + + private final PublicKey sender; + private final List recipients; + private final String content; + private final String subject; + private final String replyTo; + private final Long createdAt; + + private ChatMessage(Builder builder) { + this.sender = builder.sender; + this.recipients = List.copyOf(builder.recipients); + this.content = builder.content; + this.subject = builder.subject; + this.replyTo = builder.replyTo; + this.createdAt = builder.createdAt; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Recovers a chat message from a decrypted rumor. + * + * @param rumor a kind-14 rumor obtained by unwrapping a gift wrap + * @return the message it carries + * @throws IllegalArgumentException if the rumor is not a chat message + */ + public static ChatMessage from(@NonNull Rumor rumor) { + if (!Integer.valueOf(Kinds.CHAT_MESSAGE).equals(rumor.getKind())) { + throw new IllegalArgumentException( + "Expected a kind-" + Kinds.CHAT_MESSAGE + " chat message but found kind " + rumor.getKind()); + } + + Builder builder = + builder() + .from(rumor.getPubKey()) + .content(rumor.getContent()) + .at(rumor.getCreatedAt()); + + rumor.getReferencedPublicKeys().forEach(builder::to); + firstTagValue(rumor, SUBJECT_TAG).ifPresent(builder::subject); + firstTagValue(rumor, REPLY_TAG).ifPresent(builder::inReplyTo); + + return builder.build(); + } + + /** + * Renders this message as the unsigned rumor that gets sealed and wrapped. + * + * @return a kind-14 rumor carrying this message's content and participants + */ + public Rumor toRumor() { + List tags = new ArrayList<>(); + recipients.forEach(recipient -> tags.add(BaseTag.create(RECIPIENT_TAG, recipient.toString()))); + if (subject != null) { + tags.add(BaseTag.create(SUBJECT_TAG, subject)); + } + if (replyTo != null) { + tags.add(BaseTag.create(REPLY_TAG, replyTo)); + } + + return new Rumor(null, sender, createdAt, Kinds.CHAT_MESSAGE, tags, content); + } + + /** + * Returns everyone in this conversation: the recipients and the sender. + * + *

NIP-17 requires a copy addressed to the sender as well, since a sender who wrapped only + * for their recipients could never read their own history back. + * + * @return each participant once, recipients first + */ + public List getParticipants() { + Set participants = new LinkedHashSet<>(recipients); + participants.add(sender); + return List.copyOf(participants); + } + + public PublicKey getSender() { + return sender; + } + + public List getRecipients() { + return Collections.unmodifiableList(recipients); + } + + public String getContent() { + return content; + } + + public Optional getSubject() { + return Optional.ofNullable(subject); + } + + public Optional getReplyTo() { + return Optional.ofNullable(replyTo); + } + + public Long getCreatedAt() { + return createdAt; + } + + private static Optional firstTagValue(Rumor rumor, String code) { + return rumor.getTags().stream() + .filter(GenericTag.class::isInstance) + .map(GenericTag.class::cast) + .filter(tag -> code.equals(tag.getCode())) + .filter(tag -> !tag.getParams().isEmpty()) + .map(tag -> tag.getParams().get(0)) + .findFirst(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ChatMessage message)) { + return false; + } + return Objects.equals(sender, message.sender) + && Objects.equals(recipients, message.recipients) + && Objects.equals(content, message.content) + && Objects.equals(subject, message.subject) + && Objects.equals(replyTo, message.replyTo) + && Objects.equals(createdAt, message.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(sender, recipients, content, subject, replyTo, createdAt); + } + + @Override + public String toString() { + return "ChatMessage(sender=" + sender + ", recipients=" + recipients.size() + ")"; + } + + /** Assembles a chat message, defaulting the timestamp to now. */ + public static final class Builder { + + private final List recipients = new ArrayList<>(); + private PublicKey sender; + private String content = ""; + private String subject; + private String replyTo; + private Long createdAt; + + private Builder() {} + + /** + * Names the author. The service supplies this from its identity, so callers rarely set it. + */ + public Builder from(@NonNull PublicKey sender) { + this.sender = sender; + return this; + } + + /** Adds a recipient. Call more than once for a group conversation. */ + public Builder to(@NonNull PublicKey recipient) { + if (!recipients.contains(recipient)) { + recipients.add(recipient); + } + return this; + } + + /** Adds several recipients at once. */ + public Builder to(@NonNull List newRecipients) { + newRecipients.forEach(this::to); + return this; + } + + /** Sets the message body, which NIP-17 requires to be plain text. */ + public Builder content(@NonNull String content) { + this.content = content; + return this; + } + + /** + * Titles the conversation. The most recent subject sent to a conversation is its title, so + * this need not be repeated on every message. + */ + public Builder subject(String subject) { + this.subject = subject; + return this; + } + + /** Marks this message as a reply to another chat message. */ + public Builder inReplyTo(String parentEventId) { + this.replyTo = parentEventId; + return this; + } + + /** Overrides the creation time, which otherwise defaults to now. */ + public Builder at(Long createdAt) { + this.createdAt = createdAt; + return this; + } + + public ChatMessage build() { + if (sender == null) { + throw new IllegalStateException("A chat message needs a sender"); + } + if (recipients.isEmpty()) { + throw new IllegalStateException("A chat message needs at least one recipient"); + } + if (createdAt == null) { + createdAt = Instant.now().getEpochSecond(); + } + return new ChatMessage(this); + } + } +} diff --git a/nostr-java-event/src/main/java/nostr/event/impl/Contact.java b/nostr-java-event/src/main/java/nostr/event/impl/Contact.java new file mode 100644 index 00000000..a23a95a3 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/Contact.java @@ -0,0 +1,107 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.base.Relay; + +import java.util.Objects; +import java.util.Optional; + +/** + * Someone a follow list follows, together with where to find them and what to call them. + * + *

NIP-02 gives each entry three parts: the key, a relay where that key's events can be found, + * and a local nickname. The last two are optional and frequently empty, but they are the reason + * a follow list is more than a set of keys. The relay hint is how a client discovers where to + * look for someone it has never seen, and the petname is how it shows a human-readable name + * without a global registry. + * + *

Both are modelled as absent rather than empty, since a follow list routinely carries + * {@code ["p", key, "", ""]} and a caller asking for a petname wants to know there is none, not + * to receive a blank string to test. + * + * @see NIP-02 + */ +public final class Contact { + + private final PublicKey publicKey; + private final Relay relay; + private final String petname; + + /** + * Records a followed key, optionally with where to find them and what to call them. + * + * @param publicKey the followed key + * @param relay where that key's events can be found, or {@code null} if not known + * @param petname the local name for that profile, or {@code null} if none + */ + public Contact(@NonNull PublicKey publicKey, Relay relay, String petname) { + this.publicKey = publicKey; + this.relay = relay; + this.petname = petname == null || petname.isBlank() ? null : petname; + } + + /** + * Records a followed key with no relay hint and no petname. + * + * @param publicKey the followed key + */ + public Contact(@NonNull PublicKey publicKey) { + this(publicKey, null, null); + } + + /** + * The followed key. + * + * @return the public key + */ + public PublicKey getPublicKey() { + return publicKey; + } + + /** + * Where this contact's events can be found. + * + * @return the relay hint, or empty when the list carried none + */ + public Optional findRelay() { + return Optional.ofNullable(relay); + } + + /** + * The local name for this contact. + * + * @return the petname, or empty when the list carried none + */ + public Optional findPetname() { + return Optional.ofNullable(petname); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Contact contact)) { + return false; + } + return Objects.equals(publicKey, contact.publicKey) + && Objects.equals(relayUri(), contact.relayUri()) + && Objects.equals(petname, contact.petname); + } + + @Override + public int hashCode() { + return Objects.hash(publicKey, relayUri(), petname); + } + + @Override + public String toString() { + return "Contact(" + publicKey + findPetname().map(name -> ", " + name).orElse("") + ")"; + } + + /** {@link Relay} does not define equality by URI, so compare on the URI itself. */ + private String relayUri() { + return relay == null ? null : relay.getUri(); + } +} diff --git a/nostr-java-event/src/main/java/nostr/event/impl/ContactList.java b/nostr-java-event/src/main/java/nostr/event/impl/ContactList.java new file mode 100644 index 00000000..c9b81d91 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/ContactList.java @@ -0,0 +1,228 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.tag.GenericTag; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The profiles someone follows, as defined by NIP-02. + * + *

A follow list is replaceable and total: every published list overwrites the last, so it + * must carry every entry rather than a delta. Publishing a partial list is how an application + * accidentally unfollows everyone, which is why this type is built from a complete set of + * contacts rather than offering an "add" operation over an event. + * + *

Entries keep their order. NIP-02 asks clients to append new follows to the end so a list + * reads chronologically, and reordering on a round-trip would quietly destroy that. + * + * @see NIP-02 + */ +public final class ContactList { + + private static final String CONTACT_TAG = "p"; + private static final int PUBLIC_KEY_PARAM = 0; + private static final int RELAY_PARAM = 1; + private static final int PETNAME_PARAM = 2; + + private final PublicKey owner; + private final List contacts; + private final Long createdAt; + + /** + * Records the profiles the given key follows. + * + * @param owner the key this list belongs to + * @param contacts everyone followed, in order; a key appearing twice keeps its first entry + * @param createdAt Unix timestamp in seconds + */ + public ContactList( + @NonNull PublicKey owner, @NonNull List contacts, @NonNull Long createdAt) { + this.owner = owner; + this.contacts = List.copyOf(firstEntryPerKey(contacts)); + this.createdAt = createdAt; + } + + /** + * Reads a follow list from a kind-3 event. + * + * @param event the event to read + * @return the profiles it follows + * @throws IllegalArgumentException if the event is not a follow list + */ + public static ContactList from(@NonNull GenericEvent event) { + if (!Integer.valueOf(Kinds.CONTACT_LIST).equals(event.getKind())) { + throw new IllegalArgumentException( + "Expected a kind-" + + Kinds.CONTACT_LIST + + " follow list but found kind " + + event.getKind()); + } + + List contacts = new ArrayList<>(); + for (BaseTag tag : event.getTags()) { + readContact(tag).ifPresent(contacts::add); + } + + return new ContactList(event.getPubKey(), contacts, event.getCreatedAt()); + } + + /** + * Renders this list as the kind-3 event to publish. + * + *

The returned event is unsigned; sign it with the owner's identity before publishing. + * A contact with no relay hint still emits an empty parameter, because NIP-02 positions the + * petname third and omitting the relay would make a petname read as one. + * + * @return the event carrying this list + */ + public GenericEvent toEvent() { + List tags = new ArrayList<>(); + contacts.forEach(contact -> tags.add(toTag(contact))); + + GenericEvent event = new GenericEvent(owner, Kinds.CONTACT_LIST); + event.setTags(tags); + event.setContent(""); + event.update(createdAt); + return event; + } + + /** + * The key this list belongs to. + * + * @return the owner + */ + public PublicKey getOwner() { + return owner; + } + + /** + * Everyone this list follows, in order. + * + * @return the contacts + */ + public List getContacts() { + return Collections.unmodifiableList(contacts); + } + + /** + * The keys this list follows, for callers that need only the identities. + * + * @return the followed public keys, in order + */ + public List getFollowedKeys() { + return contacts.stream().map(Contact::getPublicKey).toList(); + } + + /** + * When this list was published. + * + * @return the Unix timestamp in seconds + */ + public Long getCreatedAt() { + return createdAt; + } + + /** + * Whether a given key is followed. + * + * @param publicKey the key to look for + * @return true when the list contains that key + */ + public boolean follows(@NonNull PublicKey publicKey) { + return contacts.stream().anyMatch(contact -> publicKey.equals(contact.getPublicKey())); + } + + /** + * Reports whether this list follows anyone. + * + *

An empty list is a legitimate state, published by someone who follows nobody, and is + * different from having no list at all. + * + * @return true when nobody is followed + */ + public boolean isEmpty() { + return contacts.isEmpty(); + } + + /** + * Reads one contact from a tag, ignoring anything that is not a usable follow entry. + * + *

Follow lists in the wild carry tags this type does not model and entries with no key at + * all. Discarding them keeps one malformed entry from making a whole list unreadable. + */ + private static Optional readContact(BaseTag tag) { + if (!(tag instanceof GenericTag generic) || !CONTACT_TAG.equals(generic.getCode())) { + return Optional.empty(); + } + List params = generic.getParams(); + if (params.isEmpty() || params.get(PUBLIC_KEY_PARAM).isBlank()) { + return Optional.empty(); + } + return Optional.of( + new Contact( + new PublicKey(params.get(PUBLIC_KEY_PARAM)), + relayFrom(params), + valueAt(params, PETNAME_PARAM))); + } + + private static Relay relayFrom(List params) { + String uri = valueAt(params, RELAY_PARAM); + return uri == null ? null : new Relay(uri); + } + + private static String valueAt(List params, int index) { + if (params.size() <= index || params.get(index).isBlank()) { + return null; + } + return params.get(index); + } + + private static BaseTag toTag(Contact contact) { + return BaseTag.create( + CONTACT_TAG, + contact.getPublicKey().toString(), + contact.findRelay().map(Relay::getUri).orElse(""), + contact.findPetname().orElse("")); + } + + /** Keeps the first entry for each key, since a duplicate key has no meaning in a follow list. */ + private static List firstEntryPerKey(List contacts) { + Map byKey = new LinkedHashMap<>(); + contacts.forEach(contact -> byKey.putIfAbsent(contact.getPublicKey(), contact)); + return List.copyOf(byKey.values()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ContactList list)) { + return false; + } + return Objects.equals(owner, list.owner) + && Objects.equals(contacts, list.contacts) + && Objects.equals(createdAt, list.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(owner, contacts, createdAt); + } + + @Override + public String toString() { + return "ContactList(owner=" + owner + ", contacts=" + contacts.size() + ")"; + } +} diff --git a/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java b/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java new file mode 100644 index 00000000..d5fb1a7e --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/DirectMessageRelayList.java @@ -0,0 +1,146 @@ +package nostr.event.impl; + +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.tag.GenericTag; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** + * The relays on which someone receives private direct messages, as defined by NIP-17. + * + *

NIP-17 requires a sender to deliver a gift wrap only to the relays its recipient nominated + * here. Publishing elsewhere does not merely risk non-delivery: it scatters a metadata-bearing + * event across relays the recipient never chose, while the one they actually read may never see + * it. + * + *

A recipient who has published no such list is signalling that they are not ready to + * receive private messages, and NIP-17 says not to send to them at all. + * + *

The specification advises keeping this list short, one to three relays, and publishing it + * widely so senders can find it. + * + * @see NIP-17 + */ +public final class DirectMessageRelayList { + + private static final String RELAY_TAG = "relay"; + + private final PublicKey owner; + private final List relays; + private final Long createdAt; + + /** + * Records the relays on which the given key receives private messages. + * + * @param owner the key this list belongs to + * @param relays the relays to nominate, in preference order; duplicates are ignored + * @param createdAt Unix timestamp in seconds + */ + public DirectMessageRelayList( + @NonNull PublicKey owner, @NonNull List relays, @NonNull Long createdAt) { + this.owner = owner; + this.relays = List.copyOf(new LinkedHashSet<>(relays)); + this.createdAt = createdAt; + } + + /** + * Reads a relay list from a kind-10050 event. + * + * @param event the event to read + * @return the relays it nominates + * @throws IllegalArgumentException if the event is not a DM relay list + */ + public static DirectMessageRelayList from(@NonNull GenericEvent event) { + if (!Integer.valueOf(Kinds.DM_RELAY_LIST).equals(event.getKind())) { + throw new IllegalArgumentException( + "Expected a kind-" + + Kinds.DM_RELAY_LIST + + " direct message relay list but found kind " + + event.getKind()); + } + + List relays = new ArrayList<>(); + for (BaseTag tag : event.getTags()) { + if (tag instanceof GenericTag generic + && RELAY_TAG.equals(generic.getCode()) + && !generic.getParams().isEmpty()) { + relays.add(new Relay(generic.getParams().get(0))); + } + } + + return new DirectMessageRelayList(event.getPubKey(), relays, event.getCreatedAt()); + } + + /** + * Renders this list as the kind-10050 event to publish. + * + *

The returned event is unsigned; sign it with the owner's identity before publishing. + * + * @return the event carrying this list + */ + public GenericEvent toEvent() { + List tags = new ArrayList<>(); + relays.forEach(relay -> tags.add(BaseTag.create(RELAY_TAG, relay.getUri()))); + + GenericEvent event = new GenericEvent(owner, Kinds.DM_RELAY_LIST); + event.setTags(tags); + event.setContent(""); + event.update(createdAt); + return event; + } + + public PublicKey getOwner() { + return owner; + } + + public List getRelays() { + return Collections.unmodifiableList(relays); + } + + public Long getCreatedAt() { + return createdAt; + } + + /** + * Reports whether this list nominates anywhere to deliver a message. + * + *

An empty list means the owner is not accepting private messages, which is a different + * situation from a relay being unreachable and should be reported differently. + * + * @return true when no relay is nominated + */ + public boolean isEmpty() { + return relays.isEmpty(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof DirectMessageRelayList list)) { + return false; + } + return Objects.equals(owner, list.owner) + && Objects.equals(relays, list.relays) + && Objects.equals(createdAt, list.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(owner, relays, createdAt); + } + + @Override + public String toString() { + return "DirectMessageRelayList(owner=" + owner + ", relays=" + relays + ")"; + } +} diff --git a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java index 7962ddb9..7adf106f 100644 --- a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java +++ b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java @@ -146,13 +146,34 @@ public void addTag(BaseTag tag) { } } + /** + * Stamps the event with the current time, then recomputes its serialization and id. + * + *

Use {@link #update(long)} when the timestamp is significant, such as the randomised + * {@code created_at} that NIP-59 requires on seals and gift wraps. + */ public void update() { + update(Instant.now().getEpochSecond()); + } + + /** + * Recomputes this event's serialization and id against the supplied creation time. + * + *

Unlike {@link #update()} this does not consult the clock, so a deliberately chosen + * {@code created_at} survives id computation. NIP-59 requires seals and gift wraps to carry + * timestamps randomised into the past to defeat time-correlation analysis, which is + * impossible if computing the id resets the timestamp. + * + * @param createdAt Unix timestamp, in seconds, to stamp the event with + * @see NIP-59 + */ + public void update(long createdAt) { try { - this.createdAt = Instant.now().getEpochSecond(); + this.createdAt = createdAt; this._serializedEvent = - nostr.event.serializer.EventSerializer.serializeToBytes( + EventSerializer.serializeToBytes( this.pubKey, this.createdAt, this.kind, this.tags, this.content); - this.id = nostr.event.serializer.EventSerializer.computeEventId(this._serializedEvent); + this.id = EventSerializer.computeEventId(this._serializedEvent); } catch (NostrException ex) { log.warn("Failed to update event during serialization: {}", ex.getMessage(), ex); throw new RuntimeException("Event update failed", ex); @@ -244,10 +265,22 @@ public Consumer getSignatureConsumer() { return this::setSignature; } + /** + * Supplies the canonical bytes that a signature is computed over. + * + *

Serialization is refreshed first so the signature covers the event's current contents. + * An event that already carries a creation time keeps it, because signing must not silently + * move an event in time: NIP-59 seals and gift wraps depend on their randomised timestamps + * surviving all the way to the wire. + */ @Transient @Override public Supplier getByteArraySupplier() { - this.update(); + if (this.createdAt != null) { + this.update(this.createdAt); + } else { + this.update(); + } if (log.isTraceEnabled()) { log.trace("Serialized event: {}", new String(this.get_serializedEvent())); } diff --git a/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java b/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java new file mode 100644 index 00000000..8f961f74 --- /dev/null +++ b/nostr-java-event/src/main/java/nostr/event/impl/Rumor.java @@ -0,0 +1,199 @@ +package nostr.event.impl; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.json.deserializer.PublicKeyDeserializer; +import nostr.event.serializer.EventSerializer; +import nostr.event.tag.GenericTag; +import nostr.util.NostrException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * An unsigned Nostr event, as defined by NIP-59. + * + *

A rumor carries content and identifies its author, but carries no signature. That absence is + * the point: a leaked rumor cannot be authenticated, which gives its author deniability, and + * relays reject it. Rumors travel only inside a seal, which is signed and encrypted. + * + *

This type deliberately does not extend {@link GenericEvent} and does not implement {@code + * ISignable}. A rumor that acquired a signature would defeat NIP-59, so the type system forbids + * it rather than relying on callers to remember. + * + *

Instances are immutable. The {@code id} is derived from the remaining fields using the + * NIP-01 canonical serialization, so it is computed rather than supplied. + * + * @see NIP-59 + * @see NIP-17 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class Rumor { + + @JsonProperty("id") + private final String id; + + @JsonProperty("pubkey") + @JsonDeserialize(using = PublicKeyDeserializer.class) + private final PublicKey pubKey; + + @JsonProperty("created_at") + private final Long createdAt; + + @JsonProperty("kind") + private final Integer kind; + + @JsonProperty("tags") + private final List tags; + + @JsonProperty("content") + private final String content; + + /** + * Reconstructs a rumor from its parts, typically after decrypting a seal. + * + *

The supplied id is retained as-is so that a received rumor can be checked against a + * recomputed id; use {@link #hasValidId()} to perform that check. + * + * @param id the event id carried by the rumor, or {@code null} to derive one + * @param pubKey the author's public key + * @param createdAt Unix timestamp in seconds + * @param kind the event kind, typically {@link nostr.base.Kinds#CHAT_MESSAGE} + * @param tags the event tags; may be empty but not null + * @param content the message content + */ + @JsonCreator + public Rumor( + @JsonProperty("id") String id, + @JsonProperty("pubkey") @NonNull PublicKey pubKey, + @JsonProperty("created_at") @NonNull Long createdAt, + @JsonProperty("kind") @NonNull Integer kind, + @JsonProperty("tags") @NonNull List tags, + @JsonProperty("content") @NonNull String content) { + this.pubKey = pubKey; + this.createdAt = createdAt; + this.kind = kind; + this.tags = List.copyOf(tags); + this.content = content; + this.id = id != null ? id : computeId(pubKey, createdAt, kind, this.tags, content); + } + + /** + * Creates a rumor stamped with the current time and a derived id. + * + * @param pubKey the author's public key + * @param kind the event kind, typically {@link nostr.base.Kinds#CHAT_MESSAGE} + * @param tags the event tags; may be empty but not null + * @param content the message content + * @return a new rumor whose id is derived from its contents + */ + public static Rumor create( + @NonNull PublicKey pubKey, + @NonNull Integer kind, + @NonNull List tags, + @NonNull String content) { + return new Rumor(null, pubKey, Instant.now().getEpochSecond(), kind, tags, content); + } + + public String getId() { + return id; + } + + public PublicKey getPubKey() { + return pubKey; + } + + public Long getCreatedAt() { + return createdAt; + } + + public Integer getKind() { + return kind; + } + + public List getTags() { + return Collections.unmodifiableList(tags); + } + + public String getContent() { + return content; + } + + /** + * Reports whether this rumor's id matches the id derived from its contents. + * + *

A received rumor arrives with an id chosen by whoever sealed it. Recomputing that id + * detects a rumor whose contents were altered after its id was set. + * + * @return true when the carried id matches the derived id + */ + @JsonIgnore + public boolean hasValidId() { + return computeId(pubKey, createdAt, kind, tags, content).equals(id); + } + + /** + * Returns the public keys named by this rumor's {@code p} tags, in order. + * + *

For a NIP-17 chat message these are the recipients of the conversation. + * + * @return the referenced public keys; empty when the rumor has no {@code p} tags + */ + @JsonIgnore + public List getReferencedPublicKeys() { + List referenced = new ArrayList<>(); + for (BaseTag tag : tags) { + if (tag instanceof GenericTag generic + && "p".equals(generic.getCode()) + && !generic.getParams().isEmpty()) { + referenced.add(new PublicKey(generic.getParams().get(0))); + } + } + return referenced; + } + + private static String computeId( + PublicKey pubKey, Long createdAt, Integer kind, List tags, String content) { + try { + return EventSerializer.computeEventId( + EventSerializer.serializeToBytes(pubKey, createdAt, kind, tags, content)); + } catch (NostrException ex) { + throw new IllegalStateException("Failed to compute rumor id", ex); + } + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Rumor rumor)) { + return false; + } + return Objects.equals(id, rumor.id) + && Objects.equals(pubKey, rumor.pubKey) + && Objects.equals(createdAt, rumor.createdAt) + && Objects.equals(kind, rumor.kind) + && Objects.equals(tags, rumor.tags) + && Objects.equals(content, rumor.content); + } + + @Override + public int hashCode() { + return Objects.hash(id, pubKey, createdAt, kind, tags, content); + } + + @Override + public String toString() { + return "Rumor(id=" + id + ", pubKey=" + pubKey + ", kind=" + kind + ")"; + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java b/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java new file mode 100644 index 00000000..c8ee9f00 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/json/RumorJsonCodecTest.java @@ -0,0 +1,150 @@ +package nostr.event.json; + +import com.fasterxml.jackson.databind.ObjectMapper; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that a rumor survives a JSON round trip with its id intact. + * + *

A rumor is serialized to JSON before being encrypted into a seal and parsed back out after + * decryption, so any escaping mismatch between the two directions corrupts a message or breaks + * its id. These tests exercise the content that a hand-written escaper typically mishandles. + * + * @see NIP-59 + */ +class RumorJsonCodecTest { + + private static final ObjectMapper MAPPER = EventJsonMapper.getMapper(); + + private static final PublicKey AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static Rumor roundTrip(Rumor rumor) throws Exception { + return MAPPER.readValue(MAPPER.writeValueAsString(rumor), Rumor.class); + } + + /** A plain rumor survives serialization and deserialization unchanged. */ + @Test + @DisplayName("round-trips a plain rumor unchanged") + void roundTripsPlainRumor() throws Exception { + Rumor original = + Rumor.create( + AUTHOR, + Kinds.CHAT_MESSAGE, + List.of(BaseTag.create("p", AUTHOR.toString())), + "Are you going to the party tonight?"); + + Rumor parsed = roundTrip(original); + + assertEquals(original, parsed); + assertTrue(parsed.hasValidId()); + } + + /** + * Content containing quotes, backslashes, newlines, control characters, and astral-plane + * Unicode round-trips with a matching id. This is precisely where a hand-written escaper + * fails, producing an event whose id does not match its content. + */ + @Test + @DisplayName("round-trips content that a naive escaper would corrupt") + void roundTripsContentNeedingEscaping() throws Exception { + String awkward = + "quote\" backslash\\ slash/ newline\n carriage\r tab\t " + + "control\u0001 null-ish\u0000 unicode\u00e9 astral\uD83D\uDE80"; + + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), awkward); + Rumor parsed = roundTrip(original); + + assertEquals(awkward, parsed.getContent()); + assertEquals(original.getId(), parsed.getId()); + assertTrue(parsed.hasValidId(), "id must still verify after a JSON round trip"); + } + + /** Tag values needing escaping survive the round trip, since tags contribute to the id. */ + @Test + @DisplayName("round-trips tag values that need escaping") + void roundTripsTagValuesNeedingEscaping() throws Exception { + Rumor original = + Rumor.create( + AUTHOR, + Kinds.CHAT_MESSAGE, + List.of(BaseTag.create("subject", "re: \"dinner\"\tand\ndrinks")), + "hello"); + + Rumor parsed = roundTrip(original); + + assertEquals(original.getId(), parsed.getId()); + assertTrue(parsed.hasValidId()); + } + + /** An empty message body is valid and keeps a verifiable id. */ + @Test + @DisplayName("round-trips empty content") + void roundTripsEmptyContent() throws Exception { + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), ""); + + Rumor parsed = roundTrip(original); + + assertEquals("", parsed.getContent()); + assertTrue(parsed.hasValidId()); + } + + /** A large message, up to the NIP-44 maximum plaintext size, round-trips intact. */ + @Test + @DisplayName("round-trips content at the NIP-44 maximum plaintext size") + void roundTripsMaximumSizeContent() throws Exception { + String large = "x".repeat(65_535); + + Rumor original = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), large); + Rumor parsed = roundTrip(original); + + assertEquals(large.length(), parsed.getContent().length()); + assertTrue(parsed.hasValidId()); + } + + /** + * The serialized form uses the wire field names from NIP-01 and carries no signature field, + * since a rumor has none to carry. + */ + @Test + @DisplayName("serializes NIP-01 wire field names and no signature") + void serializesWireFieldNames() throws Exception { + Rumor rumor = Rumor.create(AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + String json = MAPPER.writeValueAsString(rumor); + + assertTrue(json.contains("\"pubkey\"")); + assertTrue(json.contains("\"created_at\"")); + assertTrue(json.contains("\"kind\"")); + assertTrue(json.contains("\"tags\"")); + assertTrue(json.contains("\"content\"")); + assertFalse(json.contains("\"sig\""), "a rumor must never carry a signature"); + } + + /** A rumor arriving without an id gets one derived, so incoming events are always identified. */ + @Test + @DisplayName("derives an id when the incoming JSON omits one") + void derivesIdWhenAbsentFromJson() throws Exception { + String json = + "{\"pubkey\":\"" + + AUTHOR + + "\",\"created_at\":1691518405,\"kind\":1,\"tags\":[]," + + "\"content\":\"Are you going to the party tonight?\"}"; + + Rumor parsed = MAPPER.readValue(json, Rumor.class); + + assertEquals( + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9", parsed.getId()); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java b/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java new file mode 100644 index 00000000..4d70a7d1 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/ChatMessageTest.java @@ -0,0 +1,171 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the NIP-17 chat message and its translation to and from a rumor. + * + * @see NIP-17 + */ +class ChatMessageTest { + + private static final PublicKey ALICE = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final PublicKey BOB = + new PublicKey("918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"); + + private static final PublicKey CAROL = + new PublicKey("166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"); + + private static ChatMessage.Builder aMessageFromAlice() { + return ChatMessage.builder().from(ALICE).to(BOB).content("Hola, que tal?"); + } + + /** A chat message becomes the kind-14 rumor that NIP-17 specifies. */ + @Test + @DisplayName("renders as a kind-14 rumor") + void rendersAsChatMessageRumor() { + Rumor rumor = aMessageFromAlice().build().toRumor(); + + assertEquals(Kinds.CHAT_MESSAGE, rumor.getKind()); + assertEquals(ALICE, rumor.getPubKey()); + assertEquals("Hola, que tal?", rumor.getContent()); + assertTrue(rumor.hasValidId()); + } + + /** Recipients are carried as p tags, which is how NIP-17 identifies a conversation. */ + @Test + @DisplayName("carries recipients as p tags") + void carriesRecipientsAsPTags() { + Rumor rumor = aMessageFromAlice().to(CAROL).build().toRumor(); + + assertEquals(List.of(BOB, CAROL), rumor.getReferencedPublicKeys()); + } + + /** A message survives the round trip through a rumor unchanged. */ + @Test + @DisplayName("round-trips through a rumor") + void roundTripsThroughRumor() { + ChatMessage original = + aMessageFromAlice().subject("Dinner").inReplyTo("a".repeat(64)).at(1691518405L).build(); + + ChatMessage restored = ChatMessage.from(original.toRumor()); + + assertEquals(original, restored); + } + + /** The subject titles the conversation and survives the round trip. */ + @Test + @DisplayName("carries a subject") + void carriesSubject() { + ChatMessage restored = + ChatMessage.from(aMessageFromAlice().subject("Dinner").build().toRumor()); + + assertEquals("Dinner", restored.getSubject().orElseThrow()); + } + + /** A reply names its parent through an e tag. */ + @Test + @DisplayName("carries a reply reference as an e tag") + void carriesReplyReference() { + String parentId = "b".repeat(64); + + ChatMessage restored = + ChatMessage.from(aMessageFromAlice().inReplyTo(parentId).build().toRumor()); + + assertEquals(parentId, restored.getReplyTo().orElseThrow()); + } + + /** + * Participants include the sender as well as the recipients, because NIP-17 requires a copy + * addressed to the sender so they retain their own history. + */ + @Test + @DisplayName("counts the sender among the participants") + void countsSenderAmongParticipants() { + ChatMessage message = aMessageFromAlice().to(CAROL).build(); + + assertEquals(List.of(BOB, CAROL, ALICE), message.getParticipants()); + } + + /** A sender messaging only themselves appears once, not twice. */ + @Test + @DisplayName("lists a self-addressed sender only once") + void listsSelfAddressedSenderOnce() { + ChatMessage note = ChatMessage.builder().from(ALICE).to(ALICE).content("note to self").build(); + + assertEquals(List.of(ALICE), note.getParticipants()); + } + + /** A recipient named twice is carried once, so a conversation is not accidentally redefined. */ + @Test + @DisplayName("ignores a duplicate recipient") + void ignoresDuplicateRecipient() { + ChatMessage message = aMessageFromAlice().to(BOB).build(); + + assertEquals(List.of(BOB), message.getRecipients()); + } + + /** A message without a sender cannot be built, since it could not be sealed. */ + @Test + @DisplayName("refuses to build without a sender") + void refusesToBuildWithoutSender() { + ChatMessage.Builder builder = ChatMessage.builder().to(BOB).content("hello"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** A message without a recipient cannot be built, since it would reach nobody. */ + @Test + @DisplayName("refuses to build without a recipient") + void refusesToBuildWithoutRecipient() { + ChatMessage.Builder builder = ChatMessage.builder().from(ALICE).content("hello"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** A rumor of another kind is not a chat message and is refused. */ + @Test + @DisplayName("refuses to read a rumor that is not a chat message") + void refusesNonChatMessageRumor() { + Rumor note = Rumor.create(ALICE, Kinds.TEXT_NOTE, List.of(BaseTag.create("p", BOB.toString())), "hi"); + + assertThrows(IllegalArgumentException.class, () -> ChatMessage.from(note)); + } + + /** A message stamps itself with the current time when none is given. */ + @Test + @DisplayName("defaults the timestamp to now") + void defaultsTimestampToNow() { + long before = Instant.now().getEpochSecond(); + + ChatMessage message = aMessageFromAlice().build(); + + assertTrue(message.getCreatedAt() >= before); + } + + /** The string form must not disclose message content. */ + @Test + @DisplayName("omits content from toString") + void omitsContentFromToString() { + ChatMessage message = + ChatMessage.builder().from(ALICE).to(BOB).content("meet me at the usual place").build(); + + assertFalse(message.toString().contains("usual place")); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/ContactListTest.java b/nostr-java-event/src/test/java/nostr/event/unit/ContactListTest.java new file mode 100644 index 00000000..a099eb09 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/ContactListTest.java @@ -0,0 +1,189 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.impl.Contact; +import nostr.event.impl.ContactList; +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies a NIP-02 follow list survives a round trip through its event form. */ +class ContactListTest { + + private static final PublicKey OWNER = key("aa"); + private static final PublicKey ALICE = key("bb"); + private static final PublicKey BOB = key("cc"); + private static final Long CREATED_AT = 1_700_000_000L; + + // Verifies a list renders to a kind-3 event and reads back with every contact intact. + @Test + void aFollowListSurvivesARoundTrip() { + ContactList original = + new ContactList( + OWNER, + List.of( + new Contact(ALICE, new Relay("wss://alicerelay.com"), "alice"), + new Contact(BOB)), + CREATED_AT); + + ContactList readBack = ContactList.from(original.toEvent()); + + assertEquals(original, readBack); + assertEquals(List.of(ALICE, BOB), readBack.getFollowedKeys()); + } + + // Verifies the relay hint and petname survive, since those are what make a follow list more + // than a set of keys and are silently lost by any implementation that reads only the key. + @Test + void relayHintsAndPetnamesSurviveTheRoundTrip() { + ContactList original = + new ContactList( + OWNER, List.of(new Contact(ALICE, new Relay("wss://alicerelay.com"), "alice")), CREATED_AT); + + Contact readBack = ContactList.from(original.toEvent()).getContacts().getFirst(); + + assertEquals("wss://alicerelay.com", readBack.findRelay().orElseThrow().getUri()); + assertEquals("alice", readBack.findPetname().orElseThrow()); + } + + // Verifies a contact with neither hint nor petname reports them absent rather than blank, + // since a follow list routinely carries ["p", key, "", ""]. + @Test + void aContactWithoutAHintOrPetnameReportsThemAbsent() { + ContactList original = new ContactList(OWNER, List.of(new Contact(ALICE)), CREATED_AT); + + Contact readBack = ContactList.from(original.toEvent()).getContacts().getFirst(); + + assertTrue(readBack.findRelay().isEmpty()); + assertTrue(readBack.findPetname().isEmpty()); + } + + // Verifies entries keep their order, since NIP-02 asks clients to append new follows so the + // list reads chronologically. + @Test + void contactsKeepTheirOrder() { + List inOrder = + List.of(new Contact(BOB), new Contact(ALICE), new Contact(key("dd"))); + + ContactList readBack = + ContactList.from(new ContactList(OWNER, inOrder, CREATED_AT).toEvent()); + + assertEquals( + inOrder.stream().map(Contact::getPublicKey).toList(), readBack.getFollowedKeys()); + } + + // Verifies an event of the wrong kind is rejected with a message naming both kinds, so a + // caller can see what it actually passed. + @Test + void anEventOfTheWrongKindIsRejected() { + GenericEvent textNote = + GenericEvent.builder().pubKey(OWNER).kind(Kinds.TEXT_NOTE).content("not a list").build(); + + IllegalArgumentException thrown = + assertThrows(IllegalArgumentException.class, () -> ContactList.from(textNote)); + + assertTrue(thrown.getMessage().contains("kind-" + Kinds.CONTACT_LIST)); + assertTrue(thrown.getMessage().contains(String.valueOf(Kinds.TEXT_NOTE))); + } + + // Verifies a list following nobody is read as empty rather than failing, since that is a + // legitimate state and differs from having no list at all. + @Test + void aListFollowingNobodyIsEmptyRatherThanAnError() { + ContactList readBack = + ContactList.from(new ContactList(OWNER, List.of(), CREATED_AT).toEvent()); + + assertTrue(readBack.isEmpty()); + assertEquals(List.of(), readBack.getFollowedKeys()); + } + + // Verifies tags that are not contacts are ignored, so a list carrying anything else remains + // readable rather than being misread as a follow. + @Test + void tagsThatAreNotContactsAreIgnored() { + GenericEvent event = new GenericEvent(OWNER, Kinds.CONTACT_LIST); + event.setTags( + List.of( + BaseTag.create("p", ALICE.toString(), "", ""), + BaseTag.create("t", "nostr"), + BaseTag.create("e", "an-event-id"))); + event.setContent(""); + event.update(CREATED_AT); + + assertEquals(List.of(ALICE), ContactList.from(event).getFollowedKeys()); + } + + // Verifies a contact tag carrying no key is discarded, so one malformed entry cannot make a + // whole follow list unreadable. + @Test + void aContactTagWithoutAKeyIsDiscarded() { + GenericEvent event = new GenericEvent(OWNER, Kinds.CONTACT_LIST); + event.setTags(List.of(BaseTag.create("p", ""), BaseTag.create("p", ALICE.toString()))); + event.setContent(""); + event.update(CREATED_AT); + + assertEquals(List.of(ALICE), ContactList.from(event).getFollowedKeys()); + } + + // Verifies the same key twice keeps only its first entry, since a duplicate follow has no + // meaning and would otherwise be published back to relays. + @Test + void aDuplicatedKeyKeepsItsFirstEntry() { + ContactList list = + new ContactList( + OWNER, + List.of(new Contact(ALICE, null, "first"), new Contact(ALICE, null, "second")), + CREATED_AT); + + assertEquals(1, list.getContacts().size()); + assertEquals("first", list.getContacts().getFirst().findPetname().orElseThrow()); + } + + // Verifies membership can be asked directly, which is the question callers actually have. + @Test + void membershipCanBeQueried() { + ContactList list = new ContactList(OWNER, List.of(new Contact(ALICE)), CREATED_AT); + + assertTrue(list.follows(ALICE)); + assertFalse(list.follows(BOB)); + } + + // Verifies the rendered event is a kind-3 authored by the owner with no content, as NIP-02 + // specifies. + @Test + void theRenderedEventIsAnAuthoredKindThreeWithNoContent() { + GenericEvent event = + new ContactList(OWNER, List.of(new Contact(ALICE)), CREATED_AT).toEvent(); + + assertEquals(Integer.valueOf(Kinds.CONTACT_LIST), event.getKind()); + assertEquals(OWNER, event.getPubKey()); + assertEquals("", event.getContent()); + assertEquals(CREATED_AT, event.getCreatedAt()); + } + + // Verifies a petname is positioned third even when there is no relay hint, since NIP-02 reads + // tag parameters by position and a shifted petname would be read as a relay. + @Test + void aPetnameStaysInItsPositionWhenThereIsNoRelayHint() { + GenericEvent event = + new ContactList(OWNER, List.of(new Contact(ALICE, null, "alice")), CREATED_AT).toEvent(); + + GenericTag tag = (GenericTag) event.getTags().getFirst(); + + assertEquals(List.of(ALICE.toString(), "", "alice"), tag.getParams()); + } + + private static PublicKey key(String seed) { + return new PublicKey(seed.repeat(32)); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java b/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java new file mode 100644 index 00000000..8b1eabab --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/DirectMessageRelayListTest.java @@ -0,0 +1,136 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.BaseTag; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the kind-10050 list naming where someone receives private direct messages. + * + * @see NIP-17 + */ +class DirectMessageRelayListTest { + + private static final PublicKey OWNER = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final long CREATED_AT = 1691518405L; + + /** The relays published in the NIP-17 example. */ + private static final Relay INBOX = new Relay("wss://inbox.nostr.wine"); + + private static final Relay MY_RELAY = new Relay("wss://myrelay.nostr1.com"); + + private static DirectMessageRelayList aListOf(Relay... relays) { + return new DirectMessageRelayList(OWNER, List.of(relays), CREATED_AT); + } + + /** A relay list renders as the kind-10050 event NIP-17 specifies, with empty content. */ + @Test + @DisplayName("renders as a kind-10050 event") + void rendersAsDirectMessageRelayListEvent() { + GenericEvent event = aListOf(INBOX, MY_RELAY).toEvent(); + + assertEquals(Kinds.DM_RELAY_LIST, event.getKind()); + assertEquals(OWNER, event.getPubKey()); + assertEquals("", event.getContent()); + assertEquals(CREATED_AT, event.getCreatedAt()); + } + + /** Each relay is carried as a relay tag, in the order it was nominated. */ + @Test + @DisplayName("carries each relay as a relay tag in order") + void carriesRelaysAsTagsInOrder() { + GenericEvent event = aListOf(INBOX, MY_RELAY).toEvent(); + + assertEquals(2, event.getTags().size()); + assertEquals("relay", event.getTags().get(0).getCode()); + assertEquals(List.of(INBOX, MY_RELAY), DirectMessageRelayList.from(event).getRelays()); + } + + /** A relay list survives the round trip through its event. */ + @Test + @DisplayName("round-trips through its event") + void roundTripsThroughEvent() { + DirectMessageRelayList original = aListOf(INBOX, MY_RELAY); + + assertEquals(original, DirectMessageRelayList.from(original.toEvent())); + } + + /** + * An owner who nominates no relay is declining private messages, which callers must be able + * to distinguish from a relay being unreachable. + */ + @Test + @DisplayName("reports an empty list as empty") + void reportsEmptyList() { + assertTrue(aListOf().isEmpty()); + assertFalse(aListOf(INBOX).isEmpty()); + } + + /** An empty list round-trips, so "declines messages" is not confused with "no list found". */ + @Test + @DisplayName("round-trips an empty list") + void roundTripsEmptyList() { + DirectMessageRelayList empty = aListOf(); + + DirectMessageRelayList restored = DirectMessageRelayList.from(empty.toEvent()); + + assertTrue(restored.isEmpty()); + } + + /** A relay nominated twice is kept once, since duplicates would double-publish a message. */ + @Test + @DisplayName("keeps a duplicated relay only once") + void keepsDuplicateRelayOnce() { + DirectMessageRelayList list = aListOf(INBOX, MY_RELAY, INBOX); + + assertEquals(List.of(INBOX, MY_RELAY), list.getRelays()); + } + + /** An event of another kind is not a relay list and is refused. */ + @Test + @DisplayName("refuses to read an event that is not a relay list") + void refusesWrongKind() { + GenericEvent note = new GenericEvent(OWNER, Kinds.TEXT_NOTE); + note.setContent("not a relay list"); + note.update(CREATED_AT); + + assertThrows(IllegalArgumentException.class, () -> DirectMessageRelayList.from(note)); + } + + /** Tags that are not relay tags are ignored rather than misread as relays. */ + @Test + @DisplayName("ignores tags that do not name a relay") + void ignoresUnrelatedTags() { + GenericEvent event = aListOf(INBOX).toEvent(); + List mixed = + List.of( + BaseTag.create("relay", INBOX.getUri()), BaseTag.create("p", OWNER.toString())); + event.setTags(mixed); + event.update(CREATED_AT); + + assertEquals(List.of(INBOX), DirectMessageRelayList.from(event).getRelays()); + } + + /** The list a caller receives cannot be modified, keeping the instance immutable. */ + @Test + @DisplayName("exposes relays as an unmodifiable list") + void exposesUnmodifiableRelays() { + DirectMessageRelayList list = aListOf(INBOX); + + assertThrows(UnsupportedOperationException.class, () -> list.getRelays().add(MY_RELAY)); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java new file mode 100644 index 00000000..e9a4ba98 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java @@ -0,0 +1,136 @@ +package nostr.event.unit; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that computing an event id can preserve a deliberately chosen creation time. + * + *

NIP-59 requires seals and gift wraps to carry timestamps randomised into the past. That is + * impossible if computing the id resets the timestamp to the current time, so {@code + * update(long)} exists to keep the two concerns separate. + * + * @see NIP-59 + */ +class GenericEventUpdateTest { + + private static final PublicKey AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static GenericEvent anEvent() { + GenericEvent event = new GenericEvent(AUTHOR, Kinds.SEAL); + event.setContent("encrypted-payload"); + return event; + } + + /** A supplied timestamp survives id computation, which is what gift wrapping depends on. */ + @Test + @DisplayName("keeps the supplied created_at when computing the id") + void keepsSuppliedCreatedAt() { + long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); + GenericEvent event = anEvent(); + + event.update(twoDaysAgo); + + assertEquals(twoDaysAgo, event.getCreatedAt()); + } + + /** The id computed against a supplied timestamp is a real id, derived from that timestamp. */ + @Test + @DisplayName("derives an id that reflects the supplied created_at") + void derivesIdFromSuppliedCreatedAt() { + GenericEvent earlier = anEvent(); + GenericEvent later = anEvent(); + + earlier.update(1_700_000_000L); + later.update(1_700_000_001L); + + assertNotNull(earlier.getId()); + assertEquals(64, earlier.getId().length()); + assertNotEquals(earlier.getId(), later.getId()); + } + + /** Updating twice with the same timestamp is deterministic, so ids are reproducible. */ + @Test + @DisplayName("computes the same id for the same created_at") + void computesSameIdForSameCreatedAt() { + GenericEvent first = anEvent(); + GenericEvent second = anEvent(); + + first.update(1_700_000_000L); + second.update(1_700_000_000L); + + assertEquals(first.getId(), second.getId()); + } + + /** + * The no-argument overload still stamps the event with the current time, so existing callers + * are unaffected by the new seam. + */ + @Test + @DisplayName("still stamps the current time when no timestamp is supplied") + void stampsCurrentTimeWithoutArgument() { + long before = Instant.now().getEpochSecond(); + GenericEvent event = anEvent(); + + event.update(); + + long after = Instant.now().getEpochSecond(); + assertTrue(event.getCreatedAt() >= before && event.getCreatedAt() <= after); + } + + /** + * A previously chosen timestamp is discarded by the no-argument overload. This is the + * behaviour that silently defeats gift-wrap privacy, so it is pinned here to document why + * {@code update(long)} must be used for seals and wraps. + */ + @Test + @DisplayName("overwrites a preset created_at when no timestamp is supplied") + void overwritesPresetCreatedAtWithoutArgument() { + long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); + GenericEvent event = anEvent(); + event.setCreatedAt(twoDaysAgo); + + event.update(); + + assertNotEquals(twoDaysAgo, event.getCreatedAt()); + } + + /** Tags are included in the serialization that backs the id. */ + @Test + @DisplayName("includes tags in the id computed against a supplied created_at") + void includesTagsInId() { + GenericEvent untagged = anEvent(); + GenericEvent tagged = anEvent(); + tagged.setTags(List.of(BaseTag.create("p", AUTHOR.toString()))); + + untagged.update(1_700_000_000L); + tagged.update(1_700_000_000L); + + assertNotEquals(untagged.getId(), tagged.getId()); + } + + /** The cached serialization is refreshed alongside the id, so signing sees the new bytes. */ + @Test + @DisplayName("refreshes the cached serialization") + void refreshesCachedSerialization() { + GenericEvent event = anEvent(); + + event.update(1_700_000_000L); + + assertNotNull(event.getSerializedEventCache()); + assertTrue(new String(event.getSerializedEventCache()).contains("1700000000")); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java b/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java new file mode 100644 index 00000000..b526bcd3 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/RumorTest.java @@ -0,0 +1,227 @@ +package nostr.event.unit; + +import nostr.base.ISignable; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.Rumor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the unsigned rumor type introduced for NIP-59 gift wrapping. + * + * @see NIP-59 + */ +class RumorTest { + + /** The worked example published in NIP-59, section "An Example". */ + private static final PublicKey NIP59_AUTHOR = + new PublicKey("611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9"); + + private static final long NIP59_CREATED_AT = 1691518405L; + private static final String NIP59_CONTENT = "Are you going to the party tonight?"; + private static final String NIP59_EXPECTED_ID = + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9"; + + /** + * The id derived for the rumor published in NIP-59 matches the id the spec states, which pins + * canonical serialization to the specification rather than to our reading of it. + */ + @Test + @DisplayName("derives the exact event id published in the NIP-59 example") + void derivesIdFromNip59PublishedVector() { + Rumor rumor = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.TEXT_NOTE, List.of(), NIP59_CONTENT); + + assertEquals(NIP59_EXPECTED_ID, rumor.getId()); + } + + /** A rumor reconstructed with the id the spec publishes reports that id as valid. */ + @Test + @DisplayName("accepts a rumor whose carried id matches its contents") + void acceptsMatchingId() { + Rumor rumor = + new Rumor( + NIP59_EXPECTED_ID, + NIP59_AUTHOR, + NIP59_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + NIP59_CONTENT); + + assertTrue(rumor.hasValidId()); + } + + /** + * A rumor whose content was altered after its id was set is detected, which is what stops a + * tampered rumor being accepted on the receive path. + */ + @Test + @DisplayName("rejects a rumor whose carried id does not match its contents") + void rejectsTamperedId() { + Rumor tampered = + new Rumor( + NIP59_EXPECTED_ID, + NIP59_AUTHOR, + NIP59_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + "Are you going to the party tomorrow?"); + + assertFalse(tampered.hasValidId()); + } + + /** + * Content containing characters that a naive JSON escaper mishandles still produces an id that + * validates, since the id is a hash over the escaped form. + */ + @Test + @DisplayName("derives a stable id for content needing JSON escaping") + void derivesStableIdForContentNeedingEscaping() { + String awkward = "quote\" backslash\\ newline\n tab\t control\u0001 astral\uD83D\uDE80"; + + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), awkward); + Rumor reconstructed = + new Rumor( + rumor.getId(), + NIP59_AUTHOR, + rumor.getCreatedAt(), + Kinds.CHAT_MESSAGE, + List.of(), + awkward); + + assertTrue(reconstructed.hasValidId()); + assertEquals(rumor.getId(), reconstructed.getId()); + } + + /** Tags participate in the id, so two rumors differing only by tags get different ids. */ + @Test + @DisplayName("includes tags in the derived id") + void includesTagsInDerivedId() { + List withRecipient = + List.of(BaseTag.create("p", NIP59_AUTHOR.toString())); + + Rumor untagged = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + Rumor tagged = + new Rumor( + null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, withRecipient, "hello"); + + assertNotEquals(untagged.getId(), tagged.getId()); + } + + /** The recipients of a chat message are read back from its {@code p} tags, in order. */ + @Test + @DisplayName("reads recipients from p tags in order") + void readsRecipientsFromPTags() { + PublicKey first = + new PublicKey("918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"); + PublicKey second = + new PublicKey("166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99"); + + Rumor rumor = + Rumor.create( + NIP59_AUTHOR, + Kinds.CHAT_MESSAGE, + List.of( + BaseTag.create("p", first.toString()), + BaseTag.create("subject", "Dinner"), + BaseTag.create("p", second.toString())), + "hello"); + + assertEquals(List.of(first, second), rumor.getReferencedPublicKeys()); + } + + /** A rumor without p tags reports no recipients rather than failing. */ + @Test + @DisplayName("reports no recipients when the rumor has no p tags") + void reportsNoRecipientsWithoutPTags() { + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + assertTrue(rumor.getReferencedPublicKeys().isEmpty()); + } + + /** + * The tag list handed to a rumor is copied, so a caller mutating their list afterwards cannot + * change the rumor's contents behind its already-computed id. + */ + @Test + @DisplayName("copies the supplied tags so later caller mutation cannot invalidate the id") + void copiesSuppliedTags() { + List mutable = new ArrayList<>(); + mutable.add(BaseTag.create("p", NIP59_AUTHOR.toString())); + + Rumor rumor = Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, mutable, "hello"); + mutable.clear(); + + assertEquals(1, rumor.getTags().size()); + assertTrue(rumor.hasValidId()); + } + + /** The tag list a rumor hands out cannot be modified, keeping the instance immutable. */ + @Test + @DisplayName("exposes tags as an unmodifiable list") + void exposesUnmodifiableTags() { + Rumor rumor = + Rumor.create( + NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(BaseTag.create("p", "x")), "hello"); + + assertThrows( + UnsupportedOperationException.class, () -> rumor.getTags().add(BaseTag.create("e", "y"))); + } + + /** + * A rumor exposes no signature accessor. NIP-59 depends on rumors being unsignable, so this + * asserts the absence is a property of the type rather than a convention. + */ + @Test + @DisplayName("has no signature member and is not signable") + void hasNoSignatureMember() { + boolean declaresSignature = + Arrays.stream(Rumor.class.getDeclaredFields()) + .anyMatch(field -> field.getType().getSimpleName().contains("Signature")); + boolean exposesSignatureAccessor = + Arrays.stream(Rumor.class.getMethods()) + .anyMatch(method -> method.getName().toLowerCase().contains("sign")); + + assertFalse(declaresSignature, "Rumor must not hold a signature"); + assertFalse(exposesSignatureAccessor, "Rumor must not expose a signing or signature method"); + assertFalse( + ISignable.class.isAssignableFrom(Rumor.class), + "Rumor must not be signable"); + } + + /** Two rumors built from identical inputs are equal, so they can be compared after a round trip. */ + @Test + @DisplayName("treats rumors with identical contents as equal") + void treatsIdenticalRumorsAsEqual() { + Rumor first = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + Rumor second = + new Rumor(null, NIP59_AUTHOR, NIP59_CREATED_AT, Kinds.CHAT_MESSAGE, List.of(), "hello"); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + /** A rumor's string form must not disclose its content, which travels encrypted. */ + @Test + @DisplayName("omits content from toString") + void omitsContentFromToString() { + Rumor rumor = + Rumor.create(NIP59_AUTHOR, Kinds.CHAT_MESSAGE, List.of(), "meet me at the usual place"); + + assertFalse(rumor.toString().contains("usual place")); + } +} diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index 06945cd1..d09879ed 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.3.1 ../pom.xml diff --git a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java new file mode 100644 index 00000000..b97cb303 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageRelayLookup.java @@ -0,0 +1,31 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.DirectMessageRelayList; + +import java.util.Optional; + +/** + * Finds where someone receives private direct messages. + * + *

Resolving a kind-10050 list means querying relays, which the SDK's messaging types + * deliberately do not do. This interface is the seam: a caller supplies the lookup, backed by a + * relay query, a local cache, or fixed configuration, and message composition stays a pure + * function that can be verified without a network. + * + * @see NIP-17 + */ +@FunctionalInterface +public interface DirectMessageRelayLookup { + + /** + * Returns the relay list published by the given key, if there is one. + * + *

An empty result means the key has published no list, which NIP-17 treats as declining + * private messages rather than as a lookup failure. + * + * @param owner the key whose relay list is wanted + * @return their relay list, or empty when they have published none + */ + Optional findFor(PublicKey owner); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java new file mode 100644 index 00000000..60b43005 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/DirectMessageService.java @@ -0,0 +1,85 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; + +import java.util.List; +import java.util.Map; + +/** + * Composes and reads NIP-17 private direct messages. + * + *

A direct message is published as one gift wrap per participant, each encrypted separately. + * There is no shared envelope and no group identifier, which is what keeps a conversation's + * membership private, and it is why composing a message yields several events rather than one. + * + *

This service is a pure function of its inputs. It does not publish, subscribe, or retain + * messages, leaving the caller to route the events it produces and to decide what to keep. That + * separation is what allows a message to be composed and verified without a relay. + * + * @see NIP-17 + */ +public interface DirectMessageService { + + /** + * Seals and wraps a message for every participant, including its sender. + * + *

The sender's own copy is not a courtesy. A sender who published only their recipients' + * copies would be unable to read the conversation back on another device, because they cannot + * decrypt a wrap addressed to someone else. + * + *

Each returned event is addressed to exactly one participant. Publishing them all to one + * relay is possible but wasteful; prefer {@link #composeByRecipient} and route each event to + * the relays its recipient reads. + * + * @param message the message to send + * @return one signed gift wrap per participant + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + List compose(ChatMessage message); + + /** + * Seals and wraps a message, keeping each participant paired with their own event. + * + *

NIP-17 requires that a message reach a participant only through the relays that + * participant nominated, so a caller publishing to the network needs to know which event + * belongs to whom. + * + * @param message the message to send + * @return each participant's public key mapped to the wrap addressed to them + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + Map composeByRecipient(ChatMessage message); + + /** + * Seals and wraps a message, pairing each copy with the relays that should carry it. + * + *

NIP-17 permits delivery only to the relays a recipient nominated in their kind-10050 + * list, and forbids sending at all to a recipient who published none. Both rules are applied + * here, so a caller can publish the result without consulting the specification again. + * + *

An unreachable participant appears in the result carrying no event, rather than being + * omitted. Silence about a recipient who cannot be reached is how messages get lost without + * anyone noticing. + * + * @param message the message to send + * @param relayLists where to look up each participant's nominated relays + * @return one entry per participant, deliverable or not, in participant order + * @throws GiftWrapException if the message cannot be sealed or wrapped + */ + List planDelivery(ChatMessage message, DirectMessageRelayLookup relayLists); + + /** + * Opens a gift wrap addressed to this identity and returns the message inside. + * + *

The message is authenticated before it is returned: the seal's signature is verified and + * its author is checked against the rumor's, so the sender reported here is the real one. + * + * @param giftWrap a kind-1059 event addressed to this identity + * @return the authenticated message + * @throws GiftWrapException if the wrap cannot be opened or fails authentication + * @throws IllegalArgumentException if the wrap does not contain a chat message + */ + ChatMessage read(GenericEvent giftWrap); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java new file mode 100644 index 00000000..61a26ed6 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapException.java @@ -0,0 +1,17 @@ +package nostr.encryption; + +import lombok.experimental.StandardException; +import nostr.util.exception.NostrCryptoException; + +/** + * Signals that an event could not be gift wrapped, or that an incoming gift wrap was rejected. + * + *

Rejection is not always a fault. A subscription for kind-1059 events delivers wraps + * addressed to other recipients, and those cannot be opened by design. Callers reading an inbox + * should skip a wrap that raises this rather than abandoning the batch, so that one unopenable + * or malicious event cannot stall an entire conversation. + * + * @see NIP-59 + */ +@StandardException +public class GiftWrapException extends NostrCryptoException {} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java new file mode 100644 index 00000000..597696be --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/GiftWrapper.java @@ -0,0 +1,55 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; + +/** + * Hides an event's author, content, and metadata behind the NIP-59 gift wrap. + * + *

A gift wrap conceals a message in three layers. The innermost is an unsigned {@link Rumor} + * carrying the content. It is encrypted into a kind-13 seal signed by its real author, which + * proves authorship without revealing the recipient. The seal is encrypted again into a + * kind-1059 gift wrap signed by a single-use key, which reveals nothing about the author. An + * observer sees only that some random key addressed some event to a recipient. + * + *

Callers never handle a seal, an ephemeral key, or a conversation key. Two methods hide a + * rumor and reveal it; everything between is an implementation concern. + * + *

NIP-59 defines the envelope, not what travels inside it. Any event kind may be wrapped, so + * this interface is useful beyond the private direct messages of NIP-17. + * + * @see NIP-59 + */ +public interface GiftWrapper { + + /** + * Seals a rumor and wraps it for one recipient. + * + *

The returned event is signed by a freshly generated key that is used once and discarded, + * so two wraps of the same rumor cannot be linked to each other or to their author. Both the + * seal and the wrap carry timestamps randomised into the past. + * + *

To reach several recipients, call this once per recipient. Each call produces an + * independently encrypted event, which is what keeps the recipient list private. + * + * @param rumor the unsigned event to conceal + * @param recipient the public key that will be able to open the wrap + * @return a signed gift wrap ready to publish + * @throws GiftWrapException if the rumor cannot be sealed or wrapped + */ + GenericEvent wrap(Rumor rumor, PublicKey recipient); + + /** + * Opens a gift wrap addressed to this identity and returns the rumor inside. + * + *

The seal's signature is verified and its author is checked against the rumor's author + * before the rumor is returned, so a rumor obtained here has been authenticated. A rumor is + * unsigned, which means the seal's signature is the only evidence of who wrote it. + * + * @param giftWrap a kind-1059 or kind-21059 event addressed to this identity + * @return the authenticated rumor + * @throws GiftWrapException if the wrap cannot be opened, or if it fails authentication + */ + Rumor unwrap(GenericEvent giftWrap); +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java index 62993fb0..35bf97c3 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher.java @@ -2,6 +2,11 @@ public interface MessageCipher { + /** + * @deprecated NIP-04 leaves the correspondents and timing public. Prefer NIP-44, which + * {@link MessageCipher44} implements and which NIP-17 private messages build on. + */ + @Deprecated(since = "2.1.0") String NIP_04 = "NIP04"; String NIP_44 = "NIP44"; diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java index d08c624d..2526f277 100644 --- a/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageCipher04.java @@ -12,6 +12,14 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +/** + * Encrypts and decrypts messages using NIP-04. + * + * @deprecated NIP-04 hides only the message text; the correspondents and the timing stay + * public. Prefer {@link Nip17DirectMessageService}, which conceals the metadata too. + * @see NIP-17 + */ +@Deprecated(since = "2.1.0") @Data @AllArgsConstructor public class MessageCipher04 implements MessageCipher { diff --git a/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java b/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java new file mode 100644 index 00000000..c4fafdd9 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/MessageDelivery.java @@ -0,0 +1,70 @@ +package nostr.encryption; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; + +import java.util.List; +import java.util.Objects; + +/** + * Where one participant's copy of a message should be delivered. + * + *

NIP-17 requires each copy to reach only the relays its recipient nominated, so a caller + * publishing a message needs the events and their destinations paired up. + * + *

A participant who has published no relay list is not reachable. That is a deliberate + * signal, not a transient failure, and it is reported separately so a caller can tell "this + * person does not accept private messages" from "the relay was down". + * + * @param recipient the participant this copy is addressed to + * @param giftWrap the event to publish, or {@code null} when the recipient is unreachable + * @param relays the relays to publish to, empty when the recipient is unreachable + * @see NIP-17 + */ +public record MessageDelivery(PublicKey recipient, GenericEvent giftWrap, List relays) { + + public MessageDelivery { + Objects.requireNonNull(recipient, "recipient is required"); + relays = relays == null ? List.of() : List.copyOf(relays); + } + + /** + * Records a copy that can be delivered. + * + * @param recipient the participant this copy is addressed to + * @param giftWrap the event to publish + * @param relayList the recipient's nominated relays + * @return a deliverable copy + */ + public static MessageDelivery to( + @NonNull PublicKey recipient, + @NonNull GenericEvent giftWrap, + @NonNull DirectMessageRelayList relayList) { + return new MessageDelivery(recipient, giftWrap, relayList.getRelays()); + } + + /** + * Records a participant who cannot receive private messages. + * + *

NIP-17 says not to attempt delivery when a recipient has published no relay list, so no + * event is produced for them at all: an unsent wrap cannot leak. + * + * @param recipient the unreachable participant + * @return an undeliverable entry carrying no event + */ + public static MessageDelivery unreachable(@NonNull PublicKey recipient) { + return new MessageDelivery(recipient, null, List.of()); + } + + /** + * Reports whether this copy can be delivered. + * + * @return true when there is an event and somewhere to publish it + */ + public boolean isDeliverable() { + return giftWrap != null && !relays.isEmpty(); + } +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java new file mode 100644 index 00000000..32ad548c --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip17DirectMessageService.java @@ -0,0 +1,128 @@ +package nostr.encryption; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.id.Identity; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sends and reads private direct messages by gift wrapping them per NIP-17. + * + *

Every message is sealed once and wrapped separately for each participant, so the events + * published for one message share no key, no ciphertext, and no timestamp. An observer holding + * all of them learns only that several unrelated-looking events were addressed to several + * people. + * + * @see NIP-17 + */ +public class Nip17DirectMessageService implements DirectMessageService { + + private final Identity identity; + private final GiftWrapper giftWrapper; + + /** + * Creates a service that sends as, and reads for, the given identity. + * + * @param identity the identity that signs outgoing seals and opens incoming wraps + */ + public Nip17DirectMessageService(@NonNull Identity identity) { + this(identity, new Nip59GiftWrapper(identity)); + } + + /** + * Creates a service with an explicit wrapper. + * + *

Useful for ephemeral conversations, which need a wrapper configured for kind 21059, and + * for tests that fix the randomness a wrap would otherwise draw. + * + * @param identity the identity that signs outgoing seals and opens incoming wraps + * @param giftWrapper the wrapper used to conceal and reveal messages + */ + public Nip17DirectMessageService(@NonNull Identity identity, @NonNull GiftWrapper giftWrapper) { + this.identity = identity; + this.giftWrapper = giftWrapper; + } + + /** + * Starts a message authored by this service's identity. + * + *

Saves the caller naming a sender that must, in any case, match the signing identity. + * + * @return a builder with the sender already set + */ + public ChatMessage.Builder message() { + return ChatMessage.builder().from(identity.getPublicKey()); + } + + @Override + public List compose(@NonNull ChatMessage message) { + return List.copyOf(composeByRecipient(message).values()); + } + + @Override + public Map composeByRecipient(@NonNull ChatMessage message) { + Rumor rumor = senderVerifiedRumor(message); + + Map wrapsByRecipient = new LinkedHashMap<>(); + for (PublicKey participant : message.getParticipants()) { + wrapsByRecipient.put(participant, giftWrapper.wrap(rumor, participant)); + } + return wrapsByRecipient; + } + + @Override + public List planDelivery( + @NonNull ChatMessage message, @NonNull DirectMessageRelayLookup relayLists) { + Rumor rumor = senderVerifiedRumor(message); + + List plan = new ArrayList<>(); + for (PublicKey participant : message.getParticipants()) { + plan.add(deliveryFor(rumor, participant, relayLists)); + } + return List.copyOf(plan); + } + + @Override + public ChatMessage read(@NonNull GenericEvent giftWrap) { + return ChatMessage.from(giftWrapper.unwrap(giftWrap)); + } + + /** + * Wraps a message for one participant, or reports them unreachable. + * + *

Nothing is wrapped for a participant who nominated no relays. NIP-17 forbids sending to + * them, and an event that is never created cannot later be published by mistake. + */ + private MessageDelivery deliveryFor( + Rumor rumor, PublicKey participant, DirectMessageRelayLookup relayLists) { + return relayLists + .findFor(participant) + .filter(relayList -> !relayList.isEmpty()) + .map( + relayList -> + MessageDelivery.to(participant, giftWrapper.wrap(rumor, participant), relayList)) + .orElseGet(() -> MessageDelivery.unreachable(participant)); + } + + /** + * Builds the rumor to seal, refusing to send a message attributed to somebody else. + * + *

Sealing a rumor that names another author produces an event the recipient will reject as + * forged, so catching it here turns a confusing delivery failure into a clear programming + * error. + */ + private Rumor senderVerifiedRumor(ChatMessage message) { + if (!identity.getPublicKey().equals(message.getSender())) { + throw new GiftWrapException( + "Cannot send a message authored by another identity; this would be rejected as forged"); + } + return message.toRumor(); + } +} diff --git a/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java b/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java new file mode 100644 index 00000000..f71844e1 --- /dev/null +++ b/nostr-java-identity/src/main/java/nostr/encryption/Nip59GiftWrapper.java @@ -0,0 +1,272 @@ +package nostr.encryption; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.NonNull; +import nostr.base.Kinds; +import nostr.base.PrivateKey; +import nostr.base.PublicKey; +import nostr.base.Signature; +import nostr.crypto.schnorr.Schnorr; +import nostr.crypto.schnorr.SchnorrException; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.event.json.EventJsonMapper; +import nostr.event.serializer.EventSerializer; +import nostr.id.Identity; +import nostr.util.NostrException; +import nostr.util.NostrUtil; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; + +/** + * Gift wraps events according to NIP-59. + * + *

Each call to {@link #wrap} generates a key that signs exactly one event and is then + * discarded. Reusing such a key across recipients would let an observer link the wraps back + * together and identify a conversation, which is the very thing the wrap exists to prevent. + * + *

Timestamps on the seal and the wrap are drawn independently from the two days preceding + * now. NIP-59 requires them to be randomised so that events cannot be correlated by time, and + * requires them to be in the past because relays commonly reject future-dated events. + * + * @see NIP-59 + */ +public class Nip59GiftWrapper implements GiftWrapper { + + private static final long TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60L; + private static final ObjectMapper MAPPER = EventJsonMapper.getMapper(); + + private final Identity identity; + private final int wrapKind; + private final EphemeralKeySource ephemeralKeySource; + private final TimestampRandomizer timestampRandomizer; + + /** + * Creates a wrapper that produces stored gift wraps for the given identity. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + */ + public Nip59GiftWrapper(@NonNull Identity identity) { + this(identity, Kinds.GIFT_WRAP); + } + + /** + * Creates a wrapper that produces gift wraps of a chosen kind. + * + *

Use {@link Kinds#GIFT_WRAP} for messages that should be stored and delivered later, and + * {@link Kinds#EPHEMERAL_GIFT_WRAP} for real-time exchanges that relays must not retain. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + * @param wrapKind the kind to stamp on the outer event + */ + public Nip59GiftWrapper(@NonNull Identity identity, int wrapKind) { + this(identity, wrapKind, Identity::generateRandomIdentity, new PastTwoDaysRandomizer()); + } + + /** + * Creates a wrapper with explicit sources of randomness. + * + *

Intended for tests that reproduce the worked examples published in NIP-17 and NIP-59, + * which fix the ephemeral key and both timestamps. + * + * @param identity the identity that seals rumors and opens wraps addressed to it + * @param wrapKind the kind to stamp on the outer event + * @param ephemeralKeySource supplies the single-use key that signs each wrap + * @param timestampRandomizer supplies the randomised timestamps for the seal and the wrap + */ + public Nip59GiftWrapper( + @NonNull Identity identity, + int wrapKind, + @NonNull EphemeralKeySource ephemeralKeySource, + @NonNull TimestampRandomizer timestampRandomizer) { + this.identity = identity; + this.wrapKind = wrapKind; + this.ephemeralKeySource = ephemeralKeySource; + this.timestampRandomizer = timestampRandomizer; + } + + @Override + public GenericEvent wrap(@NonNull Rumor rumor, @NonNull PublicKey recipient) { + GenericEvent seal = seal(rumor, recipient); + Identity ephemeral = ephemeralKeySource.generate(); + + String encryptedSeal = encrypt(toJson(seal), ephemeral.getPrivateKey(), recipient); + + GenericEvent giftWrap = new GenericEvent(ephemeral.getPublicKey(), wrapKind); + giftWrap.setContent(encryptedSeal); + giftWrap.setTags(List.of(BaseTag.create("p", recipient.toString()))); + giftWrap.update(timestampRandomizer.randomizeFrom(Instant.now().getEpochSecond())); + ephemeral.sign(giftWrap); + + return giftWrap; + } + + @Override + public Rumor unwrap(@NonNull GenericEvent giftWrap) { + GenericEvent seal = openSeal(giftWrap); + verifySealSignature(seal); + + Rumor rumor = decryptRumor(seal); + verifyAuthorMatchesSeal(rumor, seal); + verifyRumorId(rumor); + + return rumor; + } + + /** + * Encrypts a rumor to its recipient and signs the result as a kind-13 seal. + * + *

The seal carries no tags. Anything placed on it is visible to an observer who holds the + * gift wrap, so tags here would leak the very metadata the wrap conceals. + */ + private GenericEvent seal(Rumor rumor, PublicKey recipient) { + String encryptedRumor = encrypt(toJson(rumor), identity.getPrivateKey(), recipient); + + GenericEvent seal = new GenericEvent(identity.getPublicKey(), Kinds.SEAL); + seal.setContent(encryptedRumor); + seal.setTags(List.of()); + seal.update(timestampRandomizer.randomizeFrom(rumor.getCreatedAt())); + identity.sign(seal); + + return seal; + } + + private GenericEvent openSeal(GenericEvent giftWrap) { + String sealJson = + decrypt(giftWrap.getContent(), identity.getPrivateKey(), giftWrap.getPubKey()); + GenericEvent seal = parse(sealJson, GenericEvent.class, "seal"); + + if (!Integer.valueOf(Kinds.SEAL).equals(seal.getKind())) { + throw new GiftWrapException("Gift wrap did not contain a kind-13 seal"); + } + return seal; + } + + private Rumor decryptRumor(GenericEvent seal) { + String rumorJson = decrypt(seal.getContent(), identity.getPrivateKey(), seal.getPubKey()); + return parse(rumorJson, Rumor.class, "rumor"); + } + + /** + * Confirms the seal was signed by the key it claims. + * + *

A rumor carries no signature, so the seal's signature is the only evidence of who wrote + * the message. An unverified seal is an unauthenticated message. + */ + private void verifySealSignature(GenericEvent seal) { + Signature signature = seal.getSignature(); + if (signature == null) { + throw new GiftWrapException("Seal carried no signature"); + } + + try { + byte[] serialized = + EventSerializer.serializeToBytes( + seal.getPubKey(), + seal.getCreatedAt(), + seal.getKind(), + seal.getTags(), + seal.getContent()); + boolean valid = + Schnorr.verify( + NostrUtil.sha256(serialized), + seal.getPubKey().getRawData(), + signature.getRawData()); + if (!valid) { + throw new GiftWrapException("Seal signature did not verify"); + } + } catch (SchnorrException | NoSuchAlgorithmException | NostrException ex) { + throw new GiftWrapException("Could not verify seal signature", ex); + } + } + + /** + * Confirms the rumor names the same author as the seal that carried it. + * + *

Without this check anyone could attribute a message to anyone else by editing the + * rumor's author before sealing it, because only the seal is signed. NIP-17 requires the + * comparison for exactly this reason. + */ + private void verifyAuthorMatchesSeal(Rumor rumor, GenericEvent seal) { + if (!rumor.getPubKey().equals(seal.getPubKey())) { + throw new GiftWrapException( + "Rumor author does not match the sealing key; the message is forged"); + } + } + + /** Confirms the rumor's contents still hash to the id it carries. */ + private void verifyRumorId(Rumor rumor) { + if (!rumor.hasValidId()) { + throw new GiftWrapException("Rumor id does not match its contents"); + } + } + + private String encrypt(String plaintext, PrivateKey sender, PublicKey recipient) { + return new MessageCipher44(sender.getRawData(), recipient.getRawData()).encrypt(plaintext); + } + + private String decrypt(String payload, PrivateKey self, PublicKey other) { + try { + return new MessageCipher44(self.getRawData(), other.getRawData()).decrypt(payload); + } catch (RuntimeException ex) { + throw new GiftWrapException("Could not decrypt payload; it is not addressed to us", ex); + } + } + + private static String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new GiftWrapException("Failed to serialize event for wrapping", ex); + } + } + + private static T parse(String json, Class type, String description) { + try { + return MAPPER.readValue(json, type); + } catch (JsonProcessingException ex) { + throw new GiftWrapException("Decrypted payload was not a valid " + description, ex); + } + } + + /** Supplies the single-use identity that signs one gift wrap. */ + @FunctionalInterface + public interface EphemeralKeySource { + Identity generate(); + } + + /** Chooses the randomised timestamp carried by a seal or a gift wrap. */ + @FunctionalInterface + public interface TimestampRandomizer { + + /** + * Returns a timestamp at or before {@code referenceEpochSeconds}, within two days of it. + * + * @param referenceEpochSeconds the true time being obscured + * @return the timestamp to publish + */ + long randomizeFrom(long referenceEpochSeconds); + } + + /** + * Draws a timestamp uniformly from the two days preceding the reference time. + * + *

The offset comes from {@link SecureRandom}: a predictable offset would be no protection + * when the offset is what conceals the true send time. Timestamps never move into the future, + * which NIP-59 requires and which keeps relays from dropping the event. + */ + public static final class PastTwoDaysRandomizer implements TimestampRandomizer { + + private final SecureRandom random = new SecureRandom(); + + @Override + public long randomizeFrom(long referenceEpochSeconds) { + return referenceEpochSeconds - random.nextLong(TWO_DAYS_IN_SECONDS + 1); + } + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java b/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java new file mode 100644 index 00000000..b18dd53d --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/MessageDeliveryPlanTest.java @@ -0,0 +1,206 @@ +package nostr.encryption; + +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that a message is routed only to the relays each recipient nominated. + * + * @see NIP-17 + */ +class MessageDeliveryPlanTest { + + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + private static final Relay ALICE_INBOX = new Relay("wss://alice.example"); + private static final Relay BOB_INBOX = new Relay("wss://inbox.nostr.wine"); + private static final Relay BOB_SECOND_INBOX = new Relay("wss://myrelay.nostr1.com"); + + /** A lookup backed by a map, standing in for a relay query. */ + private static final class StubRelayLookup implements DirectMessageRelayLookup { + + private final Map lists = new HashMap<>(); + + StubRelayLookup publishes(Identity owner, Relay... relays) { + lists.put( + owner.getPublicKey(), + new DirectMessageRelayList(owner.getPublicKey(), List.of(relays), 1691518405L)); + return this; + } + + @Override + public Optional findFor(PublicKey owner) { + return Optional.ofNullable(lists.get(owner)); + } + } + + private static ChatMessage aMessageToBob() { + return new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .content("Hola, que tal?") + .build(); + } + + private static MessageDelivery deliveryFor(List plan, Identity recipient) { + return plan.stream() + .filter(delivery -> delivery.recipient().equals(recipient.getPublicKey())) + .findFirst() + .orElseThrow(); + } + + /** Each participant's copy is routed only to the relays that participant nominated. */ + @Test + @DisplayName("routes each copy to its own recipient's relays") + void routesToEachRecipientsOwnRelays() { + StubRelayLookup relayLists = + new StubRelayLookup() + .publishes(ALICE, ALICE_INBOX) + .publishes(BOB, BOB_INBOX, BOB_SECOND_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(List.of(BOB_INBOX, BOB_SECOND_INBOX), deliveryFor(plan, BOB).relays()); + assertEquals(List.of(ALICE_INBOX), deliveryFor(plan, ALICE).relays()); + } + + /** Every participant appears in the plan, including the sender's own copy. */ + @Test + @DisplayName("plans a delivery for every participant") + void plansDeliveryForEveryParticipant() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(2, plan.size()); + assertTrue(plan.stream().allMatch(MessageDelivery::isDeliverable)); + } + + /** + * A recipient who published no relay list is reported unreachable and no event is created for + * them. NIP-17 forbids sending in this case, and a wrap that is never built cannot later be + * published by mistake. + */ + @Test + @DisplayName("creates no event for a recipient who published no relay list") + void createsNoEventForUnreachableRecipient() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(ALICE, ALICE_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + MessageDelivery toBob = deliveryFor(plan, BOB); + assertFalse(toBob.isDeliverable()); + assertNull(toBob.giftWrap(), "no wrap may exist for a recipient we must not send to"); + assertTrue(toBob.relays().isEmpty()); + } + + /** + * An unreachable recipient still appears in the plan. Omitting them would let a message go + * partly undelivered without the caller ever noticing. + */ + @Test + @DisplayName("still reports an unreachable recipient") + void stillReportsUnreachableRecipient() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(ALICE, ALICE_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertEquals(2, plan.size()); + assertEquals(BOB.getPublicKey(), deliveryFor(plan, BOB).recipient()); + } + + /** A recipient whose published list nominates no relay is unreachable, same as having none. */ + @Test + @DisplayName("treats an empty relay list as unreachable") + void treatsEmptyRelayListAsUnreachable() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertFalse(deliveryFor(plan, BOB).isDeliverable()); + } + + /** A deliverable copy carries an openable wrap addressed to that recipient. */ + @Test + @DisplayName("produces a wrap the recipient can open") + void producesOpenableWrap() { + StubRelayLookup relayLists = + new StubRelayLookup().publishes(ALICE, ALICE_INBOX).publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + ChatMessage received = + new Nip17DirectMessageService(BOB).read(deliveryFor(plan, BOB).giftWrap()); + + assertEquals("Hola, que tal?", received.getContent()); + assertEquals(ALICE.getPublicKey(), received.getSender()); + } + + /** A group message routes each participant's copy independently. */ + @Test + @DisplayName("routes a group message per participant") + void routesGroupMessagePerParticipant() { + Identity carol = Identity.generateRandomIdentity(); + Relay carolInbox = new Relay("wss://carol.example"); + StubRelayLookup relayLists = + new StubRelayLookup() + .publishes(ALICE, ALICE_INBOX) + .publishes(BOB, BOB_INBOX) + .publishes(carol, carolInbox); + + ChatMessage groupMessage = + new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .to(carol.getPublicKey()) + .content("dinner at eight?") + .build(); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(groupMessage, relayLists); + + assertEquals(3, plan.size()); + assertEquals(List.of(carolInbox), deliveryFor(plan, carol).relays()); + assertEquals(List.of(BOB_INBOX), deliveryFor(plan, BOB).relays()); + } + + /** A sender who published no relay list keeps no copy, and the message still reaches others. */ + @Test + @DisplayName("delivers to recipients even when the sender kept no relay list") + void deliversWhenSenderHasNoRelayList() { + StubRelayLookup relayLists = new StubRelayLookup().publishes(BOB, BOB_INBOX); + + List plan = + new Nip17DirectMessageService(ALICE).planDelivery(aMessageToBob(), relayLists); + + assertTrue(deliveryFor(plan, BOB).isDeliverable()); + assertFalse(deliveryFor(plan, ALICE).isDeliverable()); + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java b/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java new file mode 100644 index 00000000..fff1e40a --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/Nip17DirectMessageServiceTest.java @@ -0,0 +1,264 @@ +package nostr.encryption; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies sending and reading NIP-17 private direct messages. + * + * @see NIP-17 + */ +class Nip17DirectMessageServiceTest { + + /** + * Keys from the worked example published in NIP-17, section "Examples", where Alice sends + * "Hola, que tal?" to Bob. Given here in hex, since the spec quotes them as nsec. + */ + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + private static Nip17DirectMessageService serviceFor(Identity identity) { + return new Nip17DirectMessageService(identity); + } + + /** + * A message reaches its recipient with content, sender, and subject intact, which is the whole + * point of the feature. + */ + @Test + @DisplayName("delivers a message to its recipient") + void deliversMessageToRecipient() { + ChatMessage sent = + serviceFor(ALICE) + .message() + .to(BOB.getPublicKey()) + .subject("Dinner") + .content("Hola, que tal?") + .build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + ChatMessage received = serviceFor(BOB).read(wraps.get(BOB.getPublicKey())); + + assertEquals("Hola, que tal?", received.getContent()); + assertEquals(ALICE.getPublicKey(), received.getSender()); + assertEquals("Dinner", received.getSubject().orElseThrow()); + } + + /** + * A copy is addressed to the sender as well. Without it a sender could not read their own + * conversation back, since they cannot decrypt a wrap addressed to someone else. + */ + @Test + @DisplayName("wraps a copy for the sender so they keep their own history") + void wrapsCopyForSender() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + + assertTrue(wraps.containsKey(ALICE.getPublicKey()), "sender must receive their own copy"); + ChatMessage ownCopy = serviceFor(ALICE).read(wraps.get(ALICE.getPublicKey())); + assertEquals("Hola, que tal?", ownCopy.getContent()); + } + + /** A two-party message produces exactly two events: one for the recipient, one for the sender. */ + @Test + @DisplayName("produces one wrap per participant") + void producesOneWrapPerParticipant() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + List wraps = serviceFor(ALICE).compose(sent); + + assertEquals(2, wraps.size()); + } + + /** A group message reaches every participant, and each of them sees the whole recipient list. */ + @Test + @DisplayName("delivers a group message to every participant") + void deliversGroupMessage() { + Identity carol = Identity.generateRandomIdentity(); + ChatMessage sent = + serviceFor(ALICE) + .message() + .to(BOB.getPublicKey()) + .to(carol.getPublicKey()) + .content("dinner at eight?") + .build(); + + Map wraps = serviceFor(ALICE).composeByRecipient(sent); + + assertEquals(3, wraps.size()); + ChatMessage asBob = serviceFor(BOB).read(wraps.get(BOB.getPublicKey())); + ChatMessage asCarol = serviceFor(carol).read(wraps.get(carol.getPublicKey())); + assertEquals("dinner at eight?", asBob.getContent()); + assertEquals("dinner at eight?", asCarol.getContent()); + assertEquals(List.of(BOB.getPublicKey(), carol.getPublicKey()), asBob.getRecipients()); + } + + /** + * Each participant's event is independently encrypted and signed, so an observer cannot tell + * that two wraps belong to the same conversation. + */ + @Test + @DisplayName("makes each participant's wrap unlinkable to the others") + void makesWrapsUnlinkable() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + List wraps = serviceFor(ALICE).compose(sent); + GenericEvent first = wraps.get(0); + GenericEvent second = wraps.get(1); + + assertNotEquals(first.getPubKey(), second.getPubKey()); + assertNotEquals(first.getContent(), second.getContent()); + assertNotEquals(first.getId(), second.getId()); + } + + /** A participant cannot open a wrap addressed to a different participant. */ + @Test + @DisplayName("keeps a wrap unreadable by anyone but its addressee") + void keepsWrapUnreadableByOthers() { + Identity eavesdropper = Identity.generateRandomIdentity(); + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("private").build(); + + GenericEvent bobsWrap = + serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> serviceFor(eavesdropper).read(bobsWrap)); + } + + /** A reply carries a reference to the message it answers. */ + @Test + @DisplayName("preserves the parent reference on a reply") + void preservesReplyReference() { + ChatMessage original = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("dinner?").build(); + String parentId = original.toRumor().getId(); + + ChatMessage reply = + serviceFor(BOB) + .message() + .to(ALICE.getPublicKey()) + .inReplyTo(parentId) + .content("yes, eight o'clock") + .build(); + + GenericEvent wrap = serviceFor(BOB).composeByRecipient(reply).get(ALICE.getPublicKey()); + ChatMessage received = serviceFor(ALICE).read(wrap); + + assertEquals(parentId, received.getReplyTo().orElseThrow()); + } + + /** A message with no subject reports none rather than an empty one. */ + @Test + @DisplayName("reports no subject when none was set") + void reportsNoSubjectWhenUnset() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + GenericEvent wrap = serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertTrue(serviceFor(BOB).read(wrap).getSubject().isEmpty()); + } + + /** Content needing JSON escaping arrives intact. */ + @Test + @DisplayName("delivers content that needs JSON escaping") + void deliversAwkwardContent() { + String awkward = "quote\" backslash\\ newline\n control\u0001 astral\uD83D\uDE80"; + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content(awkward).build(); + + GenericEvent wrap = serviceFor(ALICE).composeByRecipient(sent).get(BOB.getPublicKey()); + + assertEquals(awkward, serviceFor(BOB).read(wrap).getContent()); + } + + /** + * Sending a message attributed to another identity is refused, since the recipient would + * reject it as forged. Failing here turns a confusing delivery failure into a clear error. + */ + @Test + @DisplayName("refuses to send a message authored by another identity") + void refusesToSendOnBehalfOfAnother() { + ChatMessage notMine = + ChatMessage.builder() + .from(BOB.getPublicKey()) + .to(ALICE.getPublicKey()) + .content("pretending to be Bob") + .build(); + + assertThrows(GiftWrapException.class, () -> serviceFor(ALICE).compose(notMine)); + } + + /** The published events never disclose the sender's key. */ + @Test + @DisplayName("never exposes the sender on a published event") + void neverExposesSender() { + ChatMessage sent = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("hello").build(); + + for (GenericEvent wrap : serviceFor(ALICE).compose(sent)) { + assertNotEquals(ALICE.getPublicKey(), wrap.getPubKey()); + assertFalse(wrap.getContent().contains(ALICE.getPublicKey().toString())); + assertEquals(Kinds.GIFT_WRAP, wrap.getKind()); + } + } + + /** A message needs a recipient, since a conversation is defined by its participants. */ + @Test + @DisplayName("refuses to build a message with no recipient") + void refusesMessageWithoutRecipient() { + ChatMessage.Builder builder = serviceFor(ALICE).message().content("into the void"); + + assertThrows(IllegalStateException.class, builder::build); + } + + /** Reading a wrap that carries something other than a chat message is refused. */ + @Test + @DisplayName("refuses to read a wrap that is not a chat message") + void refusesNonChatMessage() { + Rumor note = + Rumor.create(ALICE.getPublicKey(), Kinds.TEXT_NOTE, List.of(), "just a note"); + GenericEvent wrap = new Nip59GiftWrapper(ALICE).wrap(note, BOB.getPublicKey()); + + assertThrows(IllegalArgumentException.class, () -> serviceFor(BOB).read(wrap)); + } + + /** A conversation survives a full exchange in both directions. */ + @Test + @DisplayName("carries a conversation in both directions") + void carriesConversationBothWays() { + ChatMessage question = + serviceFor(ALICE).message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + GenericEvent toBob = serviceFor(ALICE).composeByRecipient(question).get(BOB.getPublicKey()); + + ChatMessage asRead = serviceFor(BOB).read(toBob); + ChatMessage answer = + serviceFor(BOB).message().to(asRead.getSender()).content("Muy bien, gracias").build(); + GenericEvent toAlice = serviceFor(BOB).composeByRecipient(answer).get(ALICE.getPublicKey()); + + assertEquals("Muy bien, gracias", serviceFor(ALICE).read(toAlice).getContent()); + assertEquals(BOB.getPublicKey(), serviceFor(ALICE).read(toAlice).getSender()); + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java b/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java new file mode 100644 index 00000000..e48d75fc --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/Nip59GiftWrapperTest.java @@ -0,0 +1,390 @@ +package nostr.encryption; + +import com.fasterxml.jackson.core.JsonProcessingException; +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.event.impl.Rumor; +import nostr.event.json.EventJsonMapper; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies NIP-59 gift wrapping, including the checks that authenticate an incoming message. + * + * @see NIP-59 + */ +class Nip59GiftWrapperTest { + + /** Keys from the worked example published in NIP-59, section "An Example". */ + private static final String AUTHOR_PRIVATE_KEY = + "0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273"; + + private static final String RECIPIENT_PRIVATE_KEY = + "e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45"; + + private static final String EPHEMERAL_PRIVATE_KEY = + "4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd"; + + private static final long SEAL_CREATED_AT = 1703015180L; + private static final long WRAP_CREATED_AT = 1703021488L; + private static final long RUMOR_CREATED_AT = 1691518405L; + + private static final Identity AUTHOR = Identity.create(AUTHOR_PRIVATE_KEY); + private static final Identity RECIPIENT = Identity.create(RECIPIENT_PRIVATE_KEY); + + /** A wrapper whose ephemeral key and timestamps are fixed, so output is reproducible. */ + private static Nip59GiftWrapper deterministicWrapper(Identity identity) { + return new Nip59GiftWrapper( + identity, + Kinds.GIFT_WRAP, + () -> Identity.create(EPHEMERAL_PRIVATE_KEY), + reference -> reference == RUMOR_CREATED_AT ? SEAL_CREATED_AT : WRAP_CREATED_AT); + } + + private static Rumor nip59ExampleRumor() { + return new Rumor( + null, + AUTHOR.getPublicKey(), + RUMOR_CREATED_AT, + Kinds.TEXT_NOTE, + List.of(), + "Are you going to the party tonight?"); + } + + private static Rumor aChatMessage(Identity from, PublicKey to, String content) { + return Rumor.create( + from.getPublicKey(), Kinds.CHAT_MESSAGE, List.of(BaseTag.create("p", to.toString())), content); + } + + /** + * The gift wrap produced for the NIP-59 worked example matches the published event: same + * ephemeral author, same kind, same recipient tag, and same timestamp. This pins the + * implementation to the specification rather than to our reading of it. + */ + @Test + @DisplayName("reproduces the gift wrap published in the NIP-59 example") + void reproducesPublishedGiftWrap() { + GenericEvent wrap = + deterministicWrapper(AUTHOR).wrap(nip59ExampleRumor(), RECIPIENT.getPublicKey()); + + assertEquals( + "18b1a75918f1f2c90c23da616bce317d36e348bcf5f7ba55e75949319210c87c", + wrap.getPubKey().toString(), + "wrap must be signed by the ephemeral key, not the author"); + assertEquals(Kinds.GIFT_WRAP, wrap.getKind()); + assertEquals(WRAP_CREATED_AT, wrap.getCreatedAt()); + assertEquals(1, wrap.getTags().size()); + } + + /** + * The recipient of the published example can open our wrap and recover the exact rumor, which + * confirms our ciphertext is readable by an independent reading of the spec. + */ + @Test + @DisplayName("produces a wrap the NIP-59 example recipient can open") + void producesWrapTheExampleRecipientCanOpen() { + GenericEvent wrap = + deterministicWrapper(AUTHOR).wrap(nip59ExampleRumor(), RECIPIENT.getPublicKey()); + + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals("Are you going to the party tonight?", opened.getContent()); + assertEquals(AUTHOR.getPublicKey(), opened.getPubKey()); + assertEquals( + "9dd003c6d3b73b74a85a9ab099469ce251653a7af76f523671ab828acd2a0ef9", opened.getId()); + } + + /** A rumor survives a wrap and unwrap unchanged. */ + @Test + @DisplayName("round-trips a rumor through wrap and unwrap") + void roundTripsRumor() { + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "Hola, que tal?"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(original, opened); + } + + /** Content that stresses JSON escaping survives the round trip intact. */ + @Test + @DisplayName("round-trips content that needs JSON escaping") + void roundTripsAwkwardContent() { + String awkward = "quote\" backslash\\ newline\n control\u0001 astral\uD83D\uDE80"; + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), awkward); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(awkward, opened.getContent()); + } + + /** + * A long message survives the round trip. The NIP-44 payload caps at 65,535 bytes and each + * layer wraps the one below in JSON, so the usable message is meaningfully smaller than the + * cap; this exercises a message large enough to cross several NIP-44 padding buckets. + */ + @Test + @DisplayName("round-trips a long message") + void roundTripsLargeMessage() { + Rumor original = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "x".repeat(20_000)); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(original, RECIPIENT.getPublicKey()); + Rumor opened = new Nip59GiftWrapper(RECIPIENT).unwrap(wrap); + + assertEquals(original.getContent(), opened.getContent()); + } + + /** + * A forged message, where the rumor names an author other than the key that sealed it, is + * rejected. Without this check anyone could attribute a message to anyone, because the rumor + * itself is unsigned. NIP-17 requires the comparison explicitly. + */ + @Test + @DisplayName("rejects a rumor whose author differs from the sealing key") + void rejectsImpersonatedRumor() { + Identity attacker = Identity.generateRandomIdentity(); + Rumor forged = + Rumor.create( + AUTHOR.getPublicKey(), + Kinds.CHAT_MESSAGE, + List.of(), + "Transfer the funds, this is definitely me"); + + GenericEvent wrap = new Nip59GiftWrapper(attacker).wrap(forged, RECIPIENT.getPublicKey()); + + GiftWrapException rejection = + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + assertTrue(rejection.getMessage().contains("forged")); + } + + /** + * A seal whose contents were altered after signing is rejected, because its signature no + * longer matches. Accepting it would mean accepting an unauthenticated message. + */ + @Test + @DisplayName("rejects a seal whose signature does not verify") + void rejectsSealWithInvalidSignature() { + Identity ephemeral = Identity.create(EPHEMERAL_PRIVATE_KEY); + + GenericEvent forgedSeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.SEAL); + forgedSeal.setContent("not-the-content-that-was-signed"); + forgedSeal.setTags(List.of()); + forgedSeal.update(SEAL_CREATED_AT); + // Signed by a different key than the seal claims, so verification must fail. + ephemeral.sign(forgedSeal); + + GenericEvent wrap = wrapRaw(forgedSeal, ephemeral, RECIPIENT.getPublicKey()); + + GiftWrapException rejection = + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + assertTrue(rejection.getMessage().contains("signature")); + } + + /** A seal carrying no signature at all is rejected. */ + @Test + @DisplayName("rejects a seal carrying no signature") + void rejectsUnsignedSeal() { + Identity ephemeral = Identity.generateRandomIdentity(); + + GenericEvent unsignedSeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.SEAL); + unsignedSeal.setContent("anything"); + unsignedSeal.setTags(List.of()); + unsignedSeal.update(SEAL_CREATED_AT); + + GenericEvent wrap = wrapRaw(unsignedSeal, ephemeral, RECIPIENT.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** An inner event that is not a kind-13 seal is rejected. */ + @Test + @DisplayName("rejects a wrap that does not contain a seal") + void rejectsWrapWithoutSeal() { + Identity ephemeral = Identity.generateRandomIdentity(); + + GenericEvent notASeal = new GenericEvent(AUTHOR.getPublicKey(), Kinds.TEXT_NOTE); + notASeal.setContent("a plain note, not a seal"); + notASeal.setTags(List.of()); + notASeal.update(SEAL_CREATED_AT); + AUTHOR.sign(notASeal); + + GenericEvent wrap = wrapRaw(notASeal, ephemeral, RECIPIENT.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** A wrap addressed to somebody else cannot be opened, and says so rather than failing oddly. */ + @Test + @DisplayName("rejects a wrap addressed to a different recipient") + void rejectsWrapForAnotherRecipient() { + Identity stranger = Identity.generateRandomIdentity(); + Rumor rumor = aChatMessage(AUTHOR, stranger.getPublicKey(), "not for you"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, stranger.getPublicKey()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** A wrap whose ciphertext was corrupted in transit is rejected. */ + @Test + @DisplayName("rejects a wrap with corrupted ciphertext") + void rejectsCorruptedCiphertext() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + GenericEvent corrupted = new GenericEvent(wrap.getPubKey(), Kinds.GIFT_WRAP); + corrupted.setContent(wrap.getContent().substring(0, wrap.getContent().length() - 8) + "AAAAAAAA"); + corrupted.setTags(wrap.getTags()); + corrupted.update(wrap.getCreatedAt()); + + assertThrows(GiftWrapException.class, () -> new Nip59GiftWrapper(RECIPIENT).unwrap(corrupted)); + } + + /** + * Each wrap is signed by a different key, so an observer cannot link two messages from the + * same author. Reusing an ephemeral key would undo the whole scheme. + */ + @Test + @DisplayName("signs every wrap with a distinct single-use key") + void usesDistinctEphemeralKeyPerWrap() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + Nip59GiftWrapper wrapper = new Nip59GiftWrapper(AUTHOR); + + GenericEvent first = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + GenericEvent second = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(first.getPubKey(), second.getPubKey()); + assertNotEquals(first.getContent(), second.getContent()); + assertNotEquals(first.getId(), second.getId()); + } + + /** The wrap never carries the author's key, which is the point of the outer layer. */ + @Test + @DisplayName("never exposes the author's key on the wrap") + void hidesAuthorOnTheWrap() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(AUTHOR.getPublicKey(), wrap.getPubKey()); + assertFalse(wrap.getContent().contains(AUTHOR.getPublicKey().toString())); + } + + /** + * Timestamps are randomised into the past and never into the future, since NIP-59 requires it + * and relays commonly drop future-dated events. + */ + @Test + @DisplayName("randomises timestamps into the past, never the future") + void randomisesTimestampsIntoThePast() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + Nip59GiftWrapper wrapper = new Nip59GiftWrapper(AUTHOR); + long twoDays = 2 * 24 * 60 * 60L; + + boolean anyDiffer = false; + long previous = -1; + for (int attempt = 0; attempt < 20; attempt++) { + long now = Instant.now().getEpochSecond(); + GenericEvent wrap = wrapper.wrap(rumor, RECIPIENT.getPublicKey()); + + assertTrue(wrap.getCreatedAt() <= now, "wrap must never be dated in the future"); + assertTrue(wrap.getCreatedAt() >= now - twoDays, "wrap must stay within the two-day window"); + + anyDiffer |= previous != -1 && previous != wrap.getCreatedAt(); + previous = wrap.getCreatedAt(); + } + assertTrue(anyDiffer, "timestamps must vary between wraps"); + } + + /** The true send time of the rumor is not disclosed by the wrap. */ + @Test + @DisplayName("does not reuse the rumor timestamp on the wrap") + void doesNotLeakRumorTimestamp() { + Rumor rumor = nip59ExampleRumor(); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertNotEquals(rumor.getCreatedAt(), wrap.getCreatedAt()); + } + + /** Ephemeral wraps carry kind 21059, so relays know not to store them. */ + @Test + @DisplayName("stamps ephemeral wraps with kind 21059") + void stampsEphemeralWrapKind() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = + new Nip59GiftWrapper(AUTHOR, Kinds.EPHEMERAL_GIFT_WRAP) + .wrap(rumor, RECIPIENT.getPublicKey()); + + assertEquals(Kinds.EPHEMERAL_GIFT_WRAP, wrap.getKind()); + assertEquals(rumor, new Nip59GiftWrapper(RECIPIENT).unwrap(wrap)); + } + + /** The recipient is named on the wrap so relays can route it, and nothing else is. */ + @Test + @DisplayName("tags only the recipient on the wrap") + void tagsOnlyTheRecipient() { + Rumor rumor = aChatMessage(AUTHOR, RECIPIENT.getPublicKey(), "hello"); + + GenericEvent wrap = new Nip59GiftWrapper(AUTHOR).wrap(rumor, RECIPIENT.getPublicKey()); + + assertEquals(1, wrap.getTags().size()); + assertEquals("p", wrap.getTags().get(0).getCode()); + } + + /** Round trips hold across many random identities and messages. */ + @Test + @DisplayName("round-trips across many random identities") + void roundTripsAcrossManyIdentities() { + for (int attempt = 0; attempt < 15; attempt++) { + Identity sender = Identity.generateRandomIdentity(); + Identity receiver = Identity.generateRandomIdentity(); + Rumor rumor = aChatMessage(sender, receiver.getPublicKey(), "message " + attempt); + + GenericEvent wrap = new Nip59GiftWrapper(sender).wrap(rumor, receiver.getPublicKey()); + + assertEquals(rumor, new Nip59GiftWrapper(receiver).unwrap(wrap)); + } + } + + /** + * Builds a gift wrap around an arbitrary inner event, the way an attacker would, bypassing + * the seal construction that {@link Nip59GiftWrapper#wrap} performs. This is what lets the + * tests present malformed and forged seals to the receive path. + */ + private static GenericEvent wrapRaw( + GenericEvent innerEvent, Identity ephemeral, PublicKey recipient) { + String innerJson; + try { + innerJson = EventJsonMapper.getMapper().writeValueAsString(innerEvent); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize test event", ex); + } + + String ciphertext = + new MessageCipher44(ephemeral.getPrivateKey().getRawData(), recipient.getRawData()) + .encrypt(innerJson); + + GenericEvent wrap = new GenericEvent(ephemeral.getPublicKey(), Kinds.GIFT_WRAP); + wrap.setContent(ciphertext); + wrap.setTags(List.of(BaseTag.create("p", recipient.toString()))); + wrap.update(WRAP_CREATED_AT); + ephemeral.sign(wrap); + + return wrap; + } +} diff --git a/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java b/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java new file mode 100644 index 00000000..b1751299 --- /dev/null +++ b/nostr-java-identity/src/test/java/nostr/encryption/PrivateDirectMessagesHowToTest.java @@ -0,0 +1,205 @@ +package nostr.encryption; + +import nostr.base.Kinds; +import nostr.base.PublicKey; +import nostr.base.Relay; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.DirectMessageRelayList; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Compiles and runs the examples printed in {@code docs/howto/private-direct-messages.md}. + * + *

Documentation that has drifted from the API is worse than none, so each snippet in the + * guide appears here in a form close enough to fail this test if the API changes under it. + */ +class PrivateDirectMessagesHowToTest { + + private static final Identity ALICE = + Identity.create("71f8de50a46c9996a21123280c6217c48f67d1378ff4fb14d4f7612181a1ebde"); + + private static final Identity BOB = + Identity.create("511cbb07ec2028bd2dcd039c447581a7f754df9d9a0e5c16b19a5422ab391563"); + + /** The "Publish where you receive messages" snippet. */ + @Test + @DisplayName("publishes a kind-10050 inbox list as the guide shows") + void publishesInboxList() { + Identity alice = ALICE; + + DirectMessageRelayList inbox = + new DirectMessageRelayList( + alice.getPublicKey(), + List.of(new Relay("wss://inbox.nostr.wine")), + Instant.now().getEpochSecond()); + + GenericEvent inboxEvent = inbox.toEvent(); + alice.sign(inboxEvent); + + assertEquals(Kinds.DM_RELAY_LIST, inboxEvent.getKind()); + assertTrue(inboxEvent.isSigned()); + } + + /** The "Send a message" snippet. */ + @Test + @DisplayName("composes a message as the guide shows") + void composesMessage() { + PublicKey bobPublicKey = BOB.getPublicKey(); + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + + ChatMessage message = + messages + .message() + .to(bobPublicKey) + .subject("Dinner") + .content("Are you going to the party tonight?") + .build(); + + List wraps = messages.compose(message); + + assertEquals(2, wraps.size(), "one wrap per participant, including the sender's own copy"); + } + + /** The "Send to the right relays" snippet. */ + @Test + @DisplayName("plans delivery as the guide shows") + void plansDelivery() { + Relay bobInbox = new Relay("wss://inbox.nostr.wine"); + DirectMessageRelayLookup relayLists = + pubkey -> + pubkey.equals(BOB.getPublicKey()) + ? Optional.of( + new DirectMessageRelayList(pubkey, List.of(bobInbox), Instant.now().getEpochSecond())) + : Optional.empty(); + + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + ChatMessage message = messages.message().to(BOB.getPublicKey()).content("hello").build(); + + List unreachable = new ArrayList<>(); + List published = new ArrayList<>(); + for (MessageDelivery delivery : messages.planDelivery(message, relayLists)) { + if (delivery.isDeliverable()) { + published.add(delivery.giftWrap()); + assertEquals(List.of(bobInbox), delivery.relays()); + } else { + unreachable.add(delivery.recipient()); + } + } + + assertEquals(1, published.size()); + assertEquals(List.of(ALICE.getPublicKey()), unreachable); + } + + /** The "Read your messages" snippet, including the skip-on-failure loop. */ + @Test + @DisplayName("reads an inbox as the guide shows, skipping wraps it cannot open") + void readsInboxSkippingUnopenableWraps() { + Nip17DirectMessageService fromAlice = new Nip17DirectMessageService(ALICE); + ChatMessage sent = fromAlice.message().to(BOB.getPublicKey()).content("Hola, que tal?").build(); + + Identity stranger = Identity.generateRandomIdentity(); + GenericEvent notForBob = + new Nip17DirectMessageService(stranger) + .composeByRecipient( + new Nip17DirectMessageService(stranger) + .message() + .to(stranger.getPublicKey()) + .content("someone else's business") + .build()) + .get(stranger.getPublicKey()); + + List incomingEvents = + List.of(notForBob, fromAlice.composeByRecipient(sent).get(BOB.getPublicKey())); + + Nip17DirectMessageService messages = new Nip17DirectMessageService(BOB); + List read = new ArrayList<>(); + for (GenericEvent giftWrap : incomingEvents) { + try { + ChatMessage received = messages.read(giftWrap); + read.add(received.getContent()); + } catch (GiftWrapException notForUs) { + // Expected: a kind-1059 subscription also delivers wraps we cannot open. + } + } + + assertEquals(List.of("Hola, que tal?"), read); + } + + /** The "Reply to a message" snippet. */ + @Test + @DisplayName("replies as the guide shows") + void replies() { + Nip17DirectMessageService messages = new Nip17DirectMessageService(BOB); + ChatMessage original = + new Nip17DirectMessageService(ALICE) + .message() + .to(BOB.getPublicKey()) + .content("dinner?") + .build(); + GenericEvent wrap = + new Nip17DirectMessageService(ALICE).composeByRecipient(original).get(BOB.getPublicKey()); + + ChatMessage received = messages.read(wrap); + String receivedEventId = original.toRumor().getId(); + + ChatMessage reply = + messages + .message() + .to(received.getSender()) + .inReplyTo(receivedEventId) + .content("Yes, see you at eight") + .build(); + + assertEquals(receivedEventId, reply.getReplyTo().orElseThrow()); + } + + /** The "Group conversations" snippet. */ + @Test + @DisplayName("sends a group message as the guide shows") + void sendsGroupMessage() { + PublicKey carolPublicKey = Identity.generateRandomIdentity().getPublicKey(); + Nip17DirectMessageService messages = new Nip17DirectMessageService(ALICE); + + ChatMessage groupMessage = + messages + .message() + .to(BOB.getPublicKey()) + .to(carolPublicKey) + .content("Dinner at eight?") + .build(); + + assertEquals(3, messages.compose(groupMessage).size()); + } + + /** The "Ephemeral messages" snippet. */ + @Test + @DisplayName("builds an ephemeral chat service as the guide shows") + void buildsEphemeralChatService() { + Identity alice = ALICE; + + DirectMessageService liveChat = + new Nip17DirectMessageService(alice, new Nip59GiftWrapper(alice, Kinds.EPHEMERAL_GIFT_WRAP)); + + ChatMessage message = + ChatMessage.builder() + .from(alice.getPublicKey()) + .to(BOB.getPublicKey()) + .content("are you there?") + .build(); + + assertTrue( + liveChat.compose(message).stream() + .allMatch(wrap -> Kinds.EPHEMERAL_GIFT_WRAP == wrap.getKind())); + } +} diff --git a/nostr-java-mcp/Dockerfile b/nostr-java-mcp/Dockerfile new file mode 100644 index 00000000..cc1b818f --- /dev/null +++ b/nostr-java-mcp/Dockerfile @@ -0,0 +1,51 @@ +# Builds the MCP server as a self-contained image. +# +# Two stages, so the image carries a runtime and a jar rather than a JDK, Maven, and the whole +# dependency cache. The result is smaller and has far less in it that could be exploited. +FROM maven:3.9-eclipse-temurin-21 AS build + +WORKDIR /build +# Copy the poms first so dependency resolution is cached independently of the source: editing a +# Java file should not re-download the world. +COPY pom.xml . +COPY nostr-java-core/pom.xml nostr-java-core/ +COPY nostr-java-event/pom.xml nostr-java-event/ +COPY nostr-java-identity/pom.xml nostr-java-identity/ +COPY nostr-java-client/pom.xml nostr-java-client/ +COPY nostr-java-api/pom.xml nostr-java-api/ +COPY nostr-java-mcp/pom.xml nostr-java-mcp/ +RUN mvn -B -q dependency:go-offline -DskipTests || true + +COPY . . +RUN mvn -B -q -DskipTests -Dmaven.javadoc.skip=true package + +# Distroless: no shell, no package manager, nothing to pivot to if the server is compromised. +# The server holds private keys, so the smallest possible surface is worth the loss of +# debuggability. +FROM gcr.io/distroless/java21-debian12:nonroot + +# Runs as the base image's unprivileged user. Nothing here needs root, and a container that +# does not need it should not have it. +USER nonroot + +WORKDIR /app +COPY --from=build /build/nostr-java-mcp/target/nostr-java-mcp-*-runnable.jar /app/nostr-java-mcp.jar + +# HTTP is the only transport that makes sense in a container: stdio needs the host to own the +# process, which defeats the point of packaging it. +ENV NOSTR_MCP_TRANSPORT=http + +# Binds every interface *inside* the container, which is what lets Docker's port mapping reach +# it. The container is the boundary, so the compose file publishes only to the host loopback; +# see the warning in docker-compose.yml. +ENV NOSTR_MCP_BIND_ADDRESS=0.0.0.0 +ENV NOSTR_MCP_PORT=8080 + +# A keychain does not exist in a container, so the portable backend is the only sensible default +# here. The keystore itself is mounted, never baked in. +ENV NOSTR_MCP_KEYSTORE_TYPE=encrypted-file +ENV NOSTR_MCP_KEYSTORE_PATH=/keys/keys.p12 + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/nostr-java-mcp.jar"] diff --git a/nostr-java-mcp/docker-compose.yml b/nostr-java-mcp/docker-compose.yml new file mode 100644 index 00000000..6aa60035 --- /dev/null +++ b/nostr-java-mcp/docker-compose.yml @@ -0,0 +1,74 @@ +# Runs the MCP server against a local relay, and demonstrates the bound-container pattern. +# +# Start the shared server: docker compose up mcp +# Start one server per identity: docker compose --profile bound up +services: + + # A relay to talk to, so the stack is useful without depending on the public network. + relay: + image: scsibug/nostr-rs-relay:0.8.13 + ports: + # Loopback only. This relay stores whatever it is sent, and there is no reason for + # anything outside this machine to reach it. + - "127.0.0.1:8080:8080" + + # The general server, holding every identity in the mounted keystore. + mcp: + build: + context: .. + dockerfile: nostr-java-mcp/Dockerfile + depends_on: + - relay + environment: + NOSTR_MCP_RELAYS_READ: ws://relay:8080 + NOSTR_MCP_WRITE_POLICY: confirm + # The passphrase arrives from the host environment rather than this file, so the + # keystore's protection is not committed to the repository alongside it. + NOSTR_MCP_KEYSTORE_PASSPHRASE: ${NOSTR_MCP_KEYSTORE_PASSPHRASE:?set this in your shell or a .env file} + volumes: + # Read-only: the server signs with these keys but a container should not be able to + # rewrite the operator's keystore. + - ./keys:/keys:ro + ports: + # DANGER: the MCP transport has no authentication. Anything that can reach this port can + # publish as every identity the server holds. The 127.0.0.1 prefix is what keeps it on + # this machine; without it, Docker publishes to every interface and bypasses the server's + # own loopback default entirely. Do not remove it. If you need remote access, put a + # reverse proxy with real credentials in front. + - "127.0.0.1:8090:8080" + + # One container per identity: each unlocks only its own key, so a compromise of one cannot + # reach another's. This is a process boundary rather than a check, which is why it is the + # strongest isolation the module offers. + mcp-personal: + profiles: ["bound"] + build: + context: .. + dockerfile: nostr-java-mcp/Dockerfile + depends_on: + - relay + environment: + NOSTR_MCP_RELAYS_READ: ws://relay:8080 + NOSTR_MCP_IDENTITY: personal + NOSTR_MCP_KEYSTORE_PASSPHRASE: ${NOSTR_MCP_KEYSTORE_PASSPHRASE:?set this in your shell or a .env file} + volumes: + - ./keys:/keys:ro + ports: + - "127.0.0.1:8091:8080" + + mcp-project-bot: + profiles: ["bound"] + build: + context: .. + dockerfile: nostr-java-mcp/Dockerfile + depends_on: + - relay + environment: + NOSTR_MCP_RELAYS_READ: ws://relay:8080 + NOSTR_MCP_IDENTITY: project-bot + NOSTR_MCP_WRITE_POLICY: allow + NOSTR_MCP_KEYSTORE_PASSPHRASE: ${NOSTR_MCP_KEYSTORE_PASSPHRASE:?set this in your shell or a .env file} + volumes: + - ./keys:/keys:ro + ports: + - "127.0.0.1:8092:8080" diff --git a/nostr-java-mcp/pom.xml b/nostr-java-mcp/pom.xml new file mode 100644 index 00000000..8281377f --- /dev/null +++ b/nostr-java-mcp/pom.xml @@ -0,0 +1,206 @@ + + 4.0.0 + + + xyz.tcheeric + nostr-java + 2.3.1 + ../pom.xml + + + nostr-java-mcp + jar + nostr-java-mcp + Exposes the nostr-java SDK as a Model Context Protocol server. + + + + model-driven + 2.0.1 + + 2.21 + + + + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.annotations.version} + + + + + + + reposilite-releases + https://maven.398ja.xyz/releases + + + reposilite-snapshots + https://maven.398ja.xyz/snapshots + + + + + + + ${project.groupId} + nostr-java-api + ${project.version} + + + + + io.modelcontextprotocol.sdk + mcp + ${mcp.sdk.version} + + + + + org.projectlombok + lombok + provided + + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.platform + junit-platform-launcher + test + + + xyz.tcheeric + nostr-java-client + ${project.version} + test-jar + test + + + org.apache.tomcat.embed + tomcat-embed-core + + + xyz.tcheeric + nostr-java-client + ${project.version} + test-jar + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + + org.testcontainers + ollama + test + + + + + + + src/main/resources + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + default + + integration-test + verify + + + ${excluded.it.groups} + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + true + runnable + false + + + nostr.mcp.NostrMcpApplication + + + + + META-INF/spring.handlers + + + META-INF/spring.schemas + + + META-INF/spring.factories + + + + + *:* + + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + + diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java b/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java new file mode 100644 index 00000000..7909005c --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java @@ -0,0 +1,361 @@ +package nostr.mcp; + +import lombok.NonNull; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.transport.BindAddress; +import nostr.mcp.subscription.SubscriptionLimits; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WritePolicy; + +import java.time.Duration; +import nostr.mcp.relay.RelayDirectory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The settings a server starts with, read from {@code nostr.mcp.*}. + * + *

Every value has a working default, because a server that cannot start without a + * hand-written configuration file is one an MCP host cannot launch. Defaults are read from + * system properties or the environment, so a host entry can override them on the command line + * without a file existing at all. + */ +public final class McpConfiguration { + + private static final String PREFIX = "nostr.mcp."; + private static final List DEFAULT_RELAYS = + List.of("wss://relay.damus.io", "wss://nos.lol"); + + private static final String DEFAULT_KEYSTORE_TYPE = "os-keychain"; + private static final int DEFAULT_WRITES_PER_MINUTE = 10; + private static final String DEFAULT_TRANSPORT = "stdio"; + private static final String HTTP_TRANSPORT = "http"; + private static final int DEFAULT_HTTP_PORT = 8080; + + private final List readRelays; + private final List writeRelays; + private final String keystoreType; + private final String keystorePath; + private final List identityAliases; + private final String defaultIdentity; + private final IdentityBinding identityBinding; + + private McpConfiguration( + List readRelays, + List writeRelays, + String keystoreType, + String keystorePath, + List identityAliases, + String defaultIdentity, + IdentityBinding identityBinding) { + this.readRelays = List.copyOf(readRelays); + this.writeRelays = List.copyOf(writeRelays); + this.keystoreType = keystoreType; + this.keystorePath = keystorePath; + this.identityAliases = List.copyOf(identityAliases); + this.defaultIdentity = defaultIdentity; + this.identityBinding = identityBinding; + } + + /** + * Read the configuration from system properties and the environment. + * + * @return the configuration, fully defaulted + */ + public static McpConfiguration fromEnvironment() { + List read = relayList("relays.read", DEFAULT_RELAYS); + List write = relayList("relays.write", read); + return new McpConfiguration( + read, + write, + settingOr("keystore.type", DEFAULT_KEYSTORE_TYPE), + settingOr("keystore.path", defaultKeystorePath()), + commaSeparated("identities", List.of()), + setting("identity.default"), + IdentityBinding.fromConfiguredAlias(setting("identity"))); + } + + /** + * Build a configuration directly, for tests and embedding. + * + * @param readRelays relays events are read from + * @param writeRelays relays events are published to + * @return the configuration + */ + public static McpConfiguration of( + @NonNull List readRelays, @NonNull List writeRelays) { + return new McpConfiguration( + readRelays, + writeRelays, + DEFAULT_KEYSTORE_TYPE, + defaultKeystorePath(), + List.of(), + null, + IdentityBinding.unbound()); + } + + /** + * Which keystore backend to read keys from. + * + * @return the configured backend type + */ + public String keystoreType() { + return keystoreType; + } + + /** + * Where the encrypted-file keystore lives. + * + * @return the keystore path + */ + public String keystorePath() { + return keystorePath; + } + + /** + * The identities to look for, needed by backends that cannot enumerate their own contents. + * + * @return the configured aliases + */ + public List identityAliases() { + return identityAliases; + } + + /** + * The identity to sign as when a caller names none. + * + * @return the default alias, or {@code null} when none is configured + */ + public String defaultIdentity() { + return defaultIdentity; + } + + /** + * Whether this process is bound to a single identity. + * + * @return the binding described by {@code nostr.mcp.identity} + */ + public IdentityBinding identityBinding() { + return identityBinding; + } + + /** + * The bounds every read stays inside. + * + * @return the configured limits, or the specification's defaults + */ + /** + * How much freedom an agent has to publish. + * + * @return the configured policy, defaulting to requiring confirmation + */ + /** + * Which transport to serve on. + * + * @return {@code stdio} unless HTTP was configured + */ + public String transport() { + return settingOr("transport", DEFAULT_TRANSPORT); + } + + /** + * Whether this server serves over HTTP rather than stdio. + * + * @return true when the HTTP transport was chosen + */ + public boolean usesHttpTransport() { + return HTTP_TRANSPORT.equalsIgnoreCase(transport()); + } + + /** + * Where the HTTP transport listens. + * + * @return the configured address, defaulting to loopback + */ + public BindAddress bindAddress() { + return BindAddress.fromConfiguredValue(setting("bind-address")); + } + + /** + * Which port the HTTP transport listens on. + * + * @return the configured port + */ + public int httpPort() { + return positiveIntOr("port", DEFAULT_HTTP_PORT); + } + + public WritePolicy writePolicy() { + return WritePolicy.fromConfiguredValue(setting("write-policy")); + } + + /** + * The cap on how often one identity may publish. + * + * @param clock the source of time for the sliding window + * @return the configured rate limit + */ + /** + * How much freedom an agent has to change the keystore. + * + * @return the configured policy, never more permissive than the write policy + */ + /** + * The bounds every subscription lives inside. + * + * @return the configured limits, or the specification's defaults + */ + public SubscriptionLimits subscriptionLimits() { + SubscriptionLimits defaults = SubscriptionLimits.defaults(); + return new SubscriptionLimits( + positiveIntOr("limits.max-subscriptions", defaults.maxSubscriptions()), + positiveIntOr("limits.subscription-buffer", defaults.bufferCapacity()), + durationOr("limits.subscription-idle-timeout", defaults.idleTimeout())); + } + + /** + * Which identities the model may read private messages for. + * + *

Empty by default. Decrypting correspondence puts it into the conversation and so into the + * host's logs, which is a decision for the person whose messages they are. + * + * @return the aliases whose owner has allowed decryption + */ + public java.util.Set identitiesPermittedToDecrypt() { + return java.util.Set.copyOf(commaSeparated("dm.decrypt-for", List.of())); + } + + public IdentityPolicy identityPolicy() { + return IdentityPolicy.fromConfiguredValue(setting("identity-policy"), writePolicy()); + } + + public RateLimit writeRateLimit(java.time.Clock clock) { + return new RateLimit( + positiveIntOr("limits.writes-per-minute", DEFAULT_WRITES_PER_MINUTE), + Duration.ofMinutes(1), + clock); + } + + public QueryLimits queryLimits() { + QueryLimits defaults = QueryLimits.defaults(); + return new QueryLimits( + positiveIntOr("limits.max-events-per-query", defaults.maxEventsPerQuery()), + durationOr("limits.query-timeout", defaults.queryTimeout())); + } + + /** + * Reads a positive whole number, ignoring a value that makes no sense. + * + *

A limit of zero or less would make every query return nothing, which is never what an + * operator meant, so a nonsensical setting falls back rather than disabling reads. + */ + private static int positiveIntOr(String key, int fallback) { + String value = setting(key); + if (value == null || value.isBlank()) { + return fallback; + } + try { + int parsed = Integer.parseInt(value.trim()); + return parsed > 0 ? parsed : fallback; + } catch (NumberFormatException e) { + return fallback; + } + } + + /** Accepts the {@code 15s} form an operator writes as well as plain seconds. */ + private static Duration durationOr(String key, Duration fallback) { + String value = setting(key); + if (value == null || value.isBlank()) { + return fallback; + } + String trimmed = value.trim().toLowerCase(java.util.Locale.ROOT); + try { + if (trimmed.endsWith("ms")) { + return Duration.ofMillis(Long.parseLong(trimmed.substring(0, trimmed.length() - 2))); + } + if (trimmed.endsWith("h")) { + return Duration.ofHours(Long.parseLong(trimmed.substring(0, trimmed.length() - 1))); + } + if (trimmed.endsWith("m")) { + return Duration.ofMinutes(Long.parseLong(trimmed.substring(0, trimmed.length() - 1))); + } + if (trimmed.endsWith("s")) { + return Duration.ofSeconds(Long.parseLong(trimmed.substring(0, trimmed.length() - 1))); + } + return Duration.ofSeconds(Long.parseLong(trimmed)); + } catch (NumberFormatException e) { + return fallback; + } + } + + /** + * The relay names an agent can use, mapped to the URIs they stand for. + * + * @return the directory + */ + public RelayDirectory relayDirectory() { + Map> byName = new LinkedHashMap<>(); + byName.put(RelayDirectory.READ, readRelays); + byName.put(RelayDirectory.WRITE, writeRelays); + return new RelayDirectory(byName); + } + + /** + * Every relay this server connects to at startup. + * + * @return the distinct relay URIs across read and write sets + */ + public List allRelayUris() { + return relayDirectory().allRelayUris(); + } + + private static List relayList(String key, List fallback) { + List configured = commaSeparated(key, fallback); + return configured.isEmpty() ? fallback : configured; + } + + private static List commaSeparated(String key, List fallback) { + String raw = setting(key); + if (raw == null || raw.isBlank()) { + return fallback; + } + return List.of(raw.split("\\s*,\\s*")); + } + + private static String settingOr(String key, String fallback) { + String value = setting(key); + return value == null || value.isBlank() ? fallback : value; + } + + private static String defaultKeystorePath() { + return System.getProperty("user.home") + "/.nostr-java/keys.p12"; + } + + /** Looks in system properties first, then the environment, so a host entry can override. */ + private static String setting(String key) { + String property = System.getProperty(PREFIX + key); + if (property != null) { + return property; + } + return System.getenv(environmentVariableFor(key)); + } + + /** + * The environment variable name for a setting. + * + *

Hyphens become underscores as well as dots. A shell cannot set a variable whose name + * contains a hyphen, so translating only the dots left every hyphenated setting, including + * {@code write-policy} and {@code bind-address}, impossible to configure from the environment + * and therefore from a container. + * + * @param key the setting name, as written in configuration + * @return the environment variable that sets it + */ + static String environmentVariableFor(String key) { + return ("NOSTR_MCP_" + key).toUpperCase(java.util.Locale.ROOT).replace('.', '_').replace('-', '_'); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java new file mode 100644 index 00000000..2ba6b6dd --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java @@ -0,0 +1,232 @@ +package nostr.mcp; + +import nostr.client.relay.RelayPool; +import nostr.client.relay.RelayConnection; +import nostr.client.relay.RelayConnectionFactory; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.mcp.cli.KeyAdminCli; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityStore; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.identity.KeySources; +import nostr.mcp.identity.KeystoreException; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.tool.NostrToolRegistry; +import nostr.mcp.guidance.ContextResources; +import nostr.mcp.guidance.NostrPrompts; +import nostr.mcp.social.McpDirectMessageService; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.subscription.SubscriptionResources; +import nostr.mcp.tool.ToolSurface; +import nostr.mcp.transport.HttpMcpServer; +import nostr.mcp.write.WriteGuard; + +import java.io.IOException; +import java.time.Clock; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * Launches the MCP server over stdio. + * + *

An MCP host runs this as a subprocess and speaks JSON-RPC to it over standard input and + * output, so the process must stay alive until the host closes the stream, and must keep its + * standard output clean of anything but protocol frames. + */ +@lombok.extern.slf4j.Slf4j +public final class NostrMcpApplication { + + private static final String VERSION = ServerVersion.current(); + private static final long RELAY_CONNECT_TIMEOUT_MS = 60_000L; + + private NostrMcpApplication() {} + + /** + * @param args unused; configuration comes from {@code nostr.mcp.*} properties and the + * environment, since an MCP host passes settings that way rather than positionally + */ + public static void main(String[] args) throws InterruptedException { + McpConfiguration configuration = McpConfiguration.fromEnvironment(); + List commands = commandsIn(args); + if (!commands.isEmpty()) { + System.exit(runKeyAdmin(configuration, commands)); + } + + KeySource keySource = keySource(configuration); + java.util.concurrent.atomic.AtomicReference runningServer = + new java.util.concurrent.atomic.AtomicReference<>(); + try (RelayPool relayPool = + new RelayPool(configuration.allRelayUris(), NostrMcpApplication::connectToRelay); + IdentityVault identityVault = openVault(configuration, keySource); + SubscriptionRegistry subscriptions = + new SubscriptionRegistry( + relayPool, + configuration.subscriptionLimits(), + Clock.systemUTC(), + subscriptionId -> notifyResourceChanged(runningServer, subscriptionId))) { + + NostrToolRegistry registry = + ToolSurface.forServer( + configuration.relayDirectory(), + relayPool, + identityVault, + configuration.queryLimits(), + Clock.systemUTC(), + new WriteGuard( + relayPool, + identityVault, + configuration.writePolicy(), + configuration.writeRateLimit(Clock.systemUTC())), + configuration.writePolicy(), + lifecycleFor(identityVault, keySource), + configuration.identityPolicy(), + subscriptions, + new McpDirectMessageService( + identityVault, relayPool, configuration.identitiesPermittedToDecrypt())); + + if (configuration.usesHttpTransport()) { + serveOverHttp(configuration, registry, subscriptions); + } else { + try (NostrMcpServer server = + new NostrMcpServer( + registry, + VERSION, + subscriptions, + ContextResources.all(identityVault, configuration.relayDirectory()), + NostrPrompts.all())) { + runningServer.set(server); + awaitShutdown(); + } + } + } + } + + /** + * Serves over HTTP for a deployment the host does not launch itself. + * + *

Resource notifications are not wired here. The streamable transport addresses them per + * session, and this server has no way to know which session opened which subscription, so + * pushing to all of them would leak one agent's activity to another. HTTP clients poll instead, + * which the subscription tools support unchanged. + */ + private static void serveOverHttp( + McpConfiguration configuration, + NostrToolRegistry registry, + SubscriptionRegistry subscriptions) + throws InterruptedException { + try (HttpMcpServer server = + new HttpMcpServer( + registry, + VERSION, + subscriptions, + configuration.bindAddress(), + configuration.httpPort())) { + awaitShutdown(); + } catch (IOException e) { + log.error("Could not start the MCP HTTP transport: {}", e.getMessage()); + } + } + + /** + * Tells the host that a subscription has new events. + * + *

Best-effort by design. A host that does not support resource subscriptions, or one that + * has gone away, must not be able to break the subscription that triggered this: the events + * are buffered either way and the polling tool still works. + */ + private static void notifyResourceChanged( + java.util.concurrent.atomic.AtomicReference runningServer, + String subscriptionId) { + NostrMcpServer server = runningServer.get(); + if (server == null) { + return; + } + try { + server.getServer().notifyResourcesUpdated( + new io.modelcontextprotocol.spec.McpSchema.ResourcesUpdatedNotification( + SubscriptionResources.uriFor(subscriptionId))); + } catch (RuntimeException e) { + log.debug("Could not notify the host about {}: {}", subscriptionId, e.getMessage()); + } + } + + /** + * Blocks until the host terminates the process. + * + *

The stdio transport serves on its own threads, so main has nothing left to do but stay + * out of the way; returning would end the process mid-conversation. + */ + private static void awaitShutdown() throws InterruptedException { + Thread.currentThread().join(); + } + + private static IdentityVault openVault(McpConfiguration configuration, KeySource keySource) { + return new IdentityVault( + keySource, configuration.defaultIdentity(), configuration.identityBinding()); + } + + /** + * Offers keystore administration only when the backend can actually be written to. + * + *

A remote signer or a host without a keychain can read keys and not create them, and tools + * that would always fail are worse than tools that are not there. + * + * @return the lifecycle, or {@code null} when this backend cannot be administered + */ + private static IdentityLifecycle lifecycleFor(IdentityVault identityVault, KeySource keySource) { + return keySource instanceof IdentityStore store + ? new IdentityLifecycle(identityVault, store) + : null; + } + + private static KeySource keySource(McpConfiguration configuration) { + return KeySources.forType( + configuration.keystoreType(), + configuration.keystorePath(), + configuration.identityAliases()); + } + + /** + * Separates CLI commands from the JVM options a host passes. + * + *

An MCP host launches the jar with {@code --nostr.mcp.*} flags and no command, so anything + * that is not a flag is the human asking for key administration instead. + */ + private static List commandsIn(String[] args) { + return Arrays.stream(args).filter(argument -> !argument.startsWith("-")).toList(); + } + + /** + * Runs key administration, which needs a writable backend rather than merely a readable one. + * + *

Reported as a configuration error rather than a crash, because "this backend cannot + * create keys" is something the operator can act on. + */ + private static int runKeyAdmin(McpConfiguration configuration, List commands) { + KeySource source = keySource(configuration); + if (!(source instanceof IdentityStore store)) { + System.out.println( + "error: the " + source.type() + " keystore cannot be administered from the command line"); + return 1; + } + try { + return new KeyAdminCli(store, System.out).run(commands); + } catch (KeystoreException e) { + System.out.println("error: " + e.getMessage()); + return 1; + } + } + + private static RelayConnection connectToRelay(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, RELAY_CONNECT_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while connecting to relay " + relayUri, e); + } catch (ExecutionException e) { + throw new IOException("Could not connect to relay " + relayUri, e.getCause()); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpServer.java b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpServer.java new file mode 100644 index 00000000..86161529 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpServer.java @@ -0,0 +1,158 @@ +package nostr.mcp; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import lombok.NonNull; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.subscription.SubscriptionResources; +import nostr.mcp.tool.NostrToolRegistry; + +import java.util.List; + +/** + * Serves the registered Nostr tools to an MCP host. + * + *

Bootstrapping is all this does: it hands the tool surface to the MCP SDK over a transport + * and gets out of the way. Deciding which tools exist belongs to {@link NostrToolRegistry}, and + * what they do belongs to the tools themselves. + * + *

stdio is the transport an MCP host launches directly, so the server writes protocol frames + * to standard output. Nothing else may: a stray {@code println} corrupts the stream, which is + * why logging is configured to standard error. + */ +public final class NostrMcpServer implements AutoCloseable { + + private static final String SERVER_NAME = "nostr-java-mcp"; + + private final McpSyncServer server; + + /** + * Serve the given tools over stdio. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + */ + public NostrMcpServer(@NonNull NostrToolRegistry toolRegistry, @NonNull String version) { + this(toolRegistry, version, (SubscriptionRegistry) null); + } + + /** + * Serve tools and subscription resources over stdio. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + * @param subscriptions open subscriptions to expose as resources, or {@code null} for none + */ + public NostrMcpServer( + @NonNull NostrToolRegistry toolRegistry, + @NonNull String version, + SubscriptionRegistry subscriptions) { + this( + toolRegistry, + version, + new StdioServerTransportProvider(McpJsonDefaults.getMapper()), + subscriptions, + List.of(), + List.of()); + } + + /** + * Serve tools, subscription resources, context resources and guided prompts. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + * @param subscriptions open subscriptions to expose, or {@code null} for none + * @param contextResources resources describing this server's identities and relays + * @param prompts guided sequences teaching a host how to use the tools + */ + public NostrMcpServer( + @NonNull NostrToolRegistry toolRegistry, + @NonNull String version, + SubscriptionRegistry subscriptions, + @NonNull List contextResources, + @NonNull List prompts) { + this( + toolRegistry, + version, + new StdioServerTransportProvider(McpJsonDefaults.getMapper()), + subscriptions, + contextResources, + prompts); + } + + /** + * Serve the given tools over a transport of the caller's choosing. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + * @param transportProvider the transport to serve on + */ + public NostrMcpServer( + @NonNull NostrToolRegistry toolRegistry, + @NonNull String version, + @NonNull StdioServerTransportProvider transportProvider) { + this(toolRegistry, version, transportProvider, null, List.of(), List.of()); + } + + + /** + * Serve tools, and subscription resources when there are subscriptions to serve. + * + *

Resources are advertised only when the server actually holds subscriptions, since + * declaring a capability the server cannot honour would have hosts offer an agent something + * that always comes back empty. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + * @param transportProvider the transport to serve on + * @param subscriptions open subscriptions to expose, or {@code null} for none + */ + public NostrMcpServer( + @NonNull NostrToolRegistry toolRegistry, + @NonNull String version, + @NonNull StdioServerTransportProvider transportProvider, + SubscriptionRegistry subscriptions, + @NonNull List contextResources, + @NonNull List prompts) { + List resources = new java.util.ArrayList<>(contextResources); + if (subscriptions != null) { + resources.add(SubscriptionResources.specification(subscriptions)); + } + var builder = + McpServer.sync(transportProvider) + .serverInfo(SERVER_NAME, version) + .capabilities( + ServerCapabilities.builder() + .tools(true) + .resources(!resources.isEmpty(), subscriptions != null) + .prompts(!prompts.isEmpty()) + .build()) + .tools(toolRegistry.toSpecifications()); + if (!resources.isEmpty()) { + builder = builder.resources(resources); + } + if (!prompts.isEmpty()) { + builder = builder.prompts(prompts); + } + this.server = builder.build(); + } + + /** + * The underlying MCP server, for callers that need its lifecycle directly. + * + * @return the server + */ + public McpSyncServer getServer() { + return server; + } + + @Override + public void close() { + server.close(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/ServerVersion.java b/nostr-java-mcp/src/main/java/nostr/mcp/ServerVersion.java new file mode 100644 index 00000000..8952f64d --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/ServerVersion.java @@ -0,0 +1,43 @@ +package nostr.mcp; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * The version this server reports to an MCP host. + * + *

Read from a build-filtered resource rather than written in the source, because a constant + * copied from the pom is a constant that silently disagrees with it after the next release. A + * host uses this to tell one build from another, so a stale value is worse than an absent one. + */ +@Slf4j +public final class ServerVersion { + + private static final String RESOURCE = "/nostr-mcp-build.properties"; + private static final String UNKNOWN = "unknown"; + + private ServerVersion() {} + + /** + * The built version. + * + * @return the version, or {@code unknown} when the resource is missing, which happens only + * outside a packaged build + */ + public static String current() { + try (InputStream resource = ServerVersion.class.getResourceAsStream(RESOURCE)) { + if (resource == null) { + return UNKNOWN; + } + Properties properties = new Properties(); + properties.load(resource); + return properties.getProperty("version", UNKNOWN); + } catch (IOException e) { + log.debug("Could not read the build version: {}", e.getMessage()); + return UNKNOWN; + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/argument/EventFilterArguments.java b/nostr-java-mcp/src/main/java/nostr/mcp/argument/EventFilterArguments.java new file mode 100644 index 00000000..f3c30cd7 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/argument/EventFilterArguments.java @@ -0,0 +1,108 @@ +package nostr.mcp.argument; + +import lombok.NonNull; +import nostr.event.filter.EventFilter; + +import java.time.Clock; +import java.util.List; +import java.util.Map; + +/** + * Turns tool arguments into a NIP-01 filter. + * + *

Querying and subscribing take the same filter, so the schema and the decoding live here + * rather than being written twice and drifting. An agent that has learnt to filter for one can + * use the other unchanged, which is worth more than either tool's independence. + */ +public final class EventFilterArguments { + + private EventFilterArguments() {} + + /** + * The schema shared by every filtering tool. + * + * @return the JSON schema for a filter + */ + public static Map schema() { + return schemaWith(Map.of()); + } + + /** + * The shared filter schema plus a tool's own arguments. + * + * @param additionalProperties properties this tool adds, such as a result limit + * @return the combined JSON schema + */ + public static Map schemaWith(@NonNull Map additionalProperties) { + Map properties = new java.util.LinkedHashMap<>(filterProperties()); + properties.putAll(additionalProperties); + return Map.of("type", "object", "properties", properties, "required", List.of()); + } + + private static Map filterProperties() { + return Map.of( + "authors", + Map.of( + "type", "array", + "description", "Public keys to match, as hex or npub.", + "items", Map.of("type", "string")), + "kinds", + Map.of( + "type", "array", + "description", "Event kinds to match, such as 1 for notes.", + "items", Map.of("type", "integer")), + "ids", + Map.of( + "type", "array", + "description", "Event ids to match, as hex, note or nevent.", + "items", Map.of("type", "string")), + "tags", + Map.of( + "type", + "object", + "description", + "Tag filters keyed by tag letter, such as {\"p\": [\"\"]}."), + "since", Map.of("type", "string", "description", "Only events at or after this time."), + "until", Map.of("type", "string", "description", "Only events before this time.")); + } + + /** + * Decode a filter from a call's arguments. + * + * @param arguments the caller's arguments + * @param clock what "now" means for relative times + * @return the filter + * @throws nostr.mcp.tool.ToolException when an argument is malformed + */ + public static EventFilter toFilter(@NonNull ToolArguments arguments, @NonNull Clock clock) { + return toFilterBuilder(arguments, clock).build(); + } + + /** + * Decode a filter, leaving it open for a tool to add its own terms. + * + * @param arguments the caller's arguments + * @param clock what "now" means for relative times + * @return the part-built filter + * @throws nostr.mcp.tool.ToolException when an argument is malformed + */ + public static EventFilter.Builder toFilterBuilder( + @NonNull ToolArguments arguments, @NonNull Clock clock) { + EventFilter.Builder filter = EventFilter.builder(); + arguments.texts("authors").stream() + .map(author -> NostrIdentifier.publicKey("authors", author).hex()) + .forEach(filter::author); + arguments.texts("ids").stream() + .map(id -> NostrIdentifier.eventId("ids", id).hex()) + .forEach(filter::id); + arguments.integers("kinds").forEach(filter::kind); + arguments.tagFilters("tags").forEach(filter::addTagFilter); + arguments + .text("since") + .ifPresent(since -> filter.since(TimeArgument.toUnixSeconds("since", since, clock))); + arguments + .text("until") + .ifPresent(until -> filter.until(TimeArgument.toUnixSeconds("until", until, clock))); + return filter; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/argument/NostrIdentifier.java b/nostr-java-mcp/src/main/java/nostr/mcp/argument/NostrIdentifier.java new file mode 100644 index 00000000..ee3ca1e1 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/argument/NostrIdentifier.java @@ -0,0 +1,189 @@ +package nostr.mcp.argument; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.crypto.bech32.Bech32; +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; + +import java.util.HexFormat; +import java.util.Locale; + +/** + * A public key or event id, however the caller chose to write it. + * + *

Nostr identifiers appear in two forms: the hex a relay speaks and the bech32 a person + * copies from a client. An agent will be handed whichever the user pasted, so every tool must + * accept both. Centralising that here is what stops each tool growing its own slightly different + * parser, and it means a malformed identifier produces one recognisable error instead of a + * different stack trace per tool. + * + *

The type is the guarantee: holding a {@code NostrIdentifier} means the value was decoded + * successfully, so nothing downstream re-validates it. + */ +public final class NostrIdentifier { + + private static final int HEX_LENGTH = 64; + private static final String PUBLIC_KEY_PREFIX = "npub"; + private static final String NOTE_PREFIX = "note"; + private static final String EVENT_PREFIX = "nevent"; + private static final String PRIVATE_KEY_PREFIX = "nsec"; + + private final String hex; + + private NostrIdentifier(String hex) { + this.hex = hex; + } + + /** + * Decode a public key given as hex or {@code npub}. + * + * @param argumentName the argument being decoded, so the error names what the caller wrote + * @param value the caller's text + * @return the decoded key + * @throws ToolException when the value is neither form, or is a private key + */ + public static NostrIdentifier publicKey(@NonNull String argumentName, @NonNull String value) { + return decode(argumentName, value, PUBLIC_KEY_PREFIX); + } + + /** + * Decode an event id given as hex, {@code note} or {@code nevent}. + * + * @param argumentName the argument being decoded + * @param value the caller's text + * @return the decoded id + * @throws ToolException when the value is none of those forms + */ + public static NostrIdentifier eventId(@NonNull String argumentName, @NonNull String value) { + return decode(argumentName, value, NOTE_PREFIX, EVENT_PREFIX); + } + + /** + * The identifier as a relay expects it. + * + * @return the 64-character lowercase hex form + */ + public String hex() { + return hex; + } + + /** + * The identifier as a public key. + * + * @return the key, for tools that address by it + */ + public PublicKey asPublicKey() { + return new PublicKey(hex); + } + + private static NostrIdentifier decode( + String argumentName, String value, String... acceptedPrefixes) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise(argumentName + " is empty"); + } + refusePrivateKey(argumentName, trimmed); + return isBech32(trimmed) + ? fromBech32(argumentName, trimmed, acceptedPrefixes) + : fromHex(argumentName, trimmed, acceptedPrefixes); + } + + /** + * Refuses a private key wherever a public identifier belongs. + * + *

An agent that pastes an nsec into a pubkey argument has just put a private key somewhere + * it may be logged or echoed. Rejecting it by name gives the user a chance to rotate, where a + * generic decode failure would leave the mistake invisible. + */ + private static void refusePrivateKey(String argumentName, String value) { + if (value.toLowerCase(Locale.ROOT).startsWith(PRIVATE_KEY_PREFIX)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + argumentName + + " looks like a private key (nsec). Never send a private key to a tool; treat" + + " this one as compromised and replace it."); + } + } + + private static boolean isBech32(String value) { + return value.contains("1") && !isHex(value); + } + + private static boolean isHex(String value) { + if (value.length() != HEX_LENGTH) { + return false; + } + return value.chars().allMatch(NostrIdentifier::isHexDigit); + } + + private static boolean isHexDigit(int character) { + return (character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f') + || (character >= 'A' && character <= 'F'); + } + + private static NostrIdentifier fromHex( + String argumentName, String value, String... acceptedPrefixes) { + if (!isHex(value)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + argumentName + + " is not a valid identifier; expected 64 hex characters or a " + + describe(acceptedPrefixes) + + " string"); + } + return new NostrIdentifier(value.toLowerCase(Locale.ROOT)); + } + + private static NostrIdentifier fromBech32( + String argumentName, String value, String... acceptedPrefixes) { + refuseUnexpectedPrefix(argumentName, value, acceptedPrefixes); + try { + String decoded = Bech32.fromBech32(value); + return new NostrIdentifier(decoded.toLowerCase(Locale.ROOT)); + } catch (Exception e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + argumentName + " is not a valid " + describe(acceptedPrefixes) + " string"); + } + } + + /** + * Names the mistake when the form is valid but means something else. + * + *

"an npub was given where an event id belongs" is actionable; "decode failed" is not, and + * the two are easy to confuse when both identifiers are 64 bytes of bech32. + */ + private static void refuseUnexpectedPrefix( + String argumentName, String value, String... acceptedPrefixes) { + for (String prefix : acceptedPrefixes) { + if (value.startsWith(prefix)) { + return; + } + } + throw ToolFailure.INVALID_ARGUMENT.raise( + argumentName + + " should be " + + describe(acceptedPrefixes) + + " or hex, but starts with '" + + value.substring(0, Math.min(value.indexOf('1') + 1, value.length())) + + "'"); + } + + private static String describe(String... acceptedPrefixes) { + return String.join(" or ", acceptedPrefixes); + } + + @Override + public boolean equals(Object other) { + return other instanceof NostrIdentifier identifier && hex.equals(identifier.hex); + } + + @Override + public int hashCode() { + return hex.hashCode(); + } + + @Override + public String toString() { + return hex; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/argument/TimeArgument.java b/nostr-java-mcp/src/main/java/nostr/mcp/argument/TimeArgument.java new file mode 100644 index 00000000..947914c9 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/argument/TimeArgument.java @@ -0,0 +1,105 @@ +package nostr.mcp.argument; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A point in time, however the caller chose to express it. + * + *

Relays speak Unix seconds, but a model asked to "catch up on the last day" writes + * {@code "24h"}, and a user quoting a date writes an ISO-8601 string. Accepting all three and + * normalising here means the relay boundary sees one representation and the tools never do + * arithmetic on a string. + * + *

Relative expressions are resolved against an injected clock, so tests state an exact + * expectation instead of asserting a range around "now". + */ +public final class TimeArgument { + + private static final Pattern RELATIVE = + Pattern.compile("^(\\d+)\\s*([smhdw])$", Pattern.CASE_INSENSITIVE); + + private TimeArgument() {} + + /** + * Normalise a timestamp argument to Unix seconds. + * + * @param argumentName the argument being decoded, so an error names what the caller wrote + * @param value ISO-8601, a bare date, a relative expression such as {@code 24h}, or Unix + * seconds + * @param clock what "now" means, for resolving relative expressions + * @return the instant in Unix seconds + * @throws nostr.mcp.tool.ToolException when the value is in none of those forms + */ + public static long toUnixSeconds( + @NonNull String argumentName, @NonNull String value, @NonNull Clock clock) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise(argumentName + " is empty"); + } + Matcher relative = RELATIVE.matcher(trimmed); + if (relative.matches()) { + return relativeToNow(relative, clock); + } + return absolute(argumentName, trimmed); + } + + /** + * Reads a relative expression as an age, not a future time. + * + *

"24h" in a query always means the last 24 hours: an agent asking for events cannot mean + * a day from now, because no relay holds events that have not happened. + */ + private static long relativeToNow(Matcher relative, Clock clock) { + long amount = Long.parseLong(relative.group(1)); + Duration unit = + switch (relative.group(2).toLowerCase(Locale.ROOT)) { + case "s" -> Duration.ofSeconds(1); + case "m" -> Duration.ofMinutes(1); + case "h" -> Duration.ofHours(1); + case "d" -> Duration.ofDays(1); + default -> Duration.ofDays(7); + }; + return clock.instant().minus(unit.multipliedBy(amount)).getEpochSecond(); + } + + private static long absolute(String argumentName, String value) { + if (value.chars().allMatch(Character::isDigit)) { + return Long.parseLong(value); + } + try { + return Instant.parse(value).getEpochSecond(); + } catch (DateTimeException notAnInstant) { + return startOfDay(argumentName, value); + } + } + + /** + * Reads a bare date as the start of that day in UTC. + * + *

A user writing "2026-01-01" means the whole day, and taking its start makes {@code since} + * inclusive of it, which is what they meant. + */ + private static long startOfDay(String argumentName, String value) { + try { + return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + } catch (DateTimeException notADate) { + throw ToolFailure.INVALID_ARGUMENT.raise( + argumentName + + " should be a relative age such as '24h' or '7d', an ISO-8601 timestamp, a date" + + " such as '2026-01-01', or Unix seconds, but was '" + + value + + "'"); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/argument/ToolArguments.java b/nostr-java-mcp/src/main/java/nostr/mcp/argument/ToolArguments.java new file mode 100644 index 00000000..d8d143b5 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/argument/ToolArguments.java @@ -0,0 +1,158 @@ +package nostr.mcp.argument; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Reads a tool call's arguments without every tool repeating the same casts. + * + *

MCP delivers arguments as an untyped map, so a tool that reads them directly is a tool full + * of unchecked casts, each of which fails as a {@code ClassCastException} the agent cannot act + * on. Reading through here turns a wrong type into an {@code INVALID_ARGUMENT} that names the + * argument and says what was expected. + * + *

A model will also send a number as a string, or a single value where a list is allowed, + * because JSON schemas are a suggestion to a language model rather than a constraint. Coercing + * those here is the difference between a tool that works with real agents and one that is + * correct on paper. + */ +public final class ToolArguments { + + private final Map arguments; + + /** + * @param arguments the raw arguments from the tool call, which may be null + */ + public ToolArguments(Map arguments) { + this.arguments = arguments == null ? Map.of() : arguments; + } + + /** + * Read a required text argument. + * + * @param name the argument to read + * @return its value + * @throws nostr.mcp.tool.ToolException when it is absent or blank + */ + public String requireText(@NonNull String name) { + return text(name) + .orElseThrow(() -> ToolFailure.INVALID_ARGUMENT.raise("'" + name + "' is required")); + } + + /** + * Read an optional text argument. + * + * @param name the argument to read + * @return its value, or empty when absent or blank + */ + public Optional text(@NonNull String name) { + Object value = arguments.get(name); + if (value == null) { + return Optional.empty(); + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? Optional.empty() : Optional.of(text); + } + + /** + * Read an optional whole-number argument. + * + * @param name the argument to read + * @return its value, or empty when absent + * @throws nostr.mcp.tool.ToolException when present but not a whole number + */ + public Optional integer(@NonNull String name) { + Optional text = text(name); + if (text.isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of((int) Double.parseDouble(text.get())); + } catch (NumberFormatException e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + name + "' should be a number but was '" + text.get() + "'"); + } + } + + /** + * Read a list argument, accepting a single value in place of a one-element list. + * + * @param name the argument to read + * @return the values, empty when absent + */ + public List texts(@NonNull String name) { + Object value = arguments.get(name); + return switch (value) { + case null -> List.of(); + case List list -> list.stream().map(String::valueOf).map(String::trim).filter(text -> !text.isEmpty()).toList(); + default -> text(name).map(List::of).orElseGet(List::of); + }; + } + + /** + * Read a list of whole numbers, accepting a single value in place of a list. + * + * @param name the argument to read + * @return the values, empty when absent + * @throws nostr.mcp.tool.ToolException when any value is not a whole number + */ + public List integers(@NonNull String name) { + return texts(name).stream().map(text -> parseInteger(name, text)).toList(); + } + + /** + * Read the tag filters, which NIP-01 writes as single-letter keys. + * + * @param name the argument holding them + * @return each tag letter and the values to match + */ + public Map> tagFilters(@NonNull String name) { + Object value = arguments.get(name); + if (!(value instanceof Map map)) { + return Map.of(); + } + return map.entrySet().stream() + .collect( + java.util.stream.Collectors.toMap( + entry -> String.valueOf(entry.getKey()), + entry -> new ToolArguments(Map.of("v", entry.getValue())).texts("v"), + (first, second) -> first, + java.util.LinkedHashMap::new)); + } + + /** + * Read a list of lists, as NIP-01 writes tags. + * + *

A single flat list is read as one entry, since a model given an example of nested arrays + * will sometimes send just the inner one. + * + * @param name the argument to read + * @return each inner list's values + */ + public List> nestedTexts(@NonNull String name) { + Object value = arguments.get(name); + if (!(value instanceof List outer) || outer.isEmpty()) { + return List.of(); + } + if (outer.stream().noneMatch(List.class::isInstance)) { + return List.of(texts(name)); + } + return outer.stream() + .filter(List.class::isInstance) + .map(inner -> new ToolArguments(Map.of("v", inner)).texts("v")) + .toList(); + } + + private int parseInteger(String name, String text) { + try { + return (int) Double.parseDouble(text); + } catch (NumberFormatException e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + name + "' should contain only numbers but had '" + text + "'"); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/cli/KeyAdminCli.java b/nostr-java-mcp/src/main/java/nostr/mcp/cli/KeyAdminCli.java new file mode 100644 index 00000000..a64db79a --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/cli/KeyAdminCli.java @@ -0,0 +1,200 @@ +package nostr.mcp.cli; + +import lombok.NonNull; +import nostr.base.PrivateKey; +import nostr.crypto.bech32.Bech32; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityStore; +import nostr.mcp.identity.KeystoreException; + +import java.io.PrintStream; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; + +/** + * Key administration for the human who sets the servers up. + * + *

Deliberately not a tool. In the process-per-identity deployment an agent operates one key + * and never administers a keystore, so creating and destroying keys lives on the command line, + * out of every agent's reach. The same jar serves both, because a separate admin artefact is one + * more thing to version and to get out of step. + * + *

Writes to a caller-supplied stream rather than {@code System.out} so the stdio transport is + * never in doubt: this class is used before the server exists, and a stray print into a live + * JSON-RPC stream would corrupt the protocol. + */ +public final class KeyAdminCli { + + private static final String NSEC_PREFIX = "nsec"; + + private final IdentityStore store; + private final PrintStream output; + + /** + * @param store the keystore to administer + * @param output where to report results + */ + public KeyAdminCli(@NonNull IdentityStore store, @NonNull PrintStream output) { + this.store = store; + this.output = output; + } + + /** + * Run one command. + * + * @param arguments the command and its operands, as given on the command line + * @return the process exit code, zero on success + */ + public int run(@NonNull List arguments) { + if (arguments.isEmpty()) { + return usage(); + } + try { + return dispatch(arguments.getFirst(), arguments.subList(1, arguments.size())); + } catch (KeystoreException e) { + output.println("error: " + e.getMessage()); + return 1; + } + } + + private int dispatch(String command, List operands) { + return switch (command) { + case "keygen" -> keygen(operands); + case "import" -> importKey(operands); + case "list" -> list(); + case "remove" -> remove(operands); + default -> unknown(command); + }; + } + + /** + * Creates a keypair and reports the public half only. + * + *

The private key is generated, stored and wiped without ever being printed. A person who + * needs a backup exports one deliberately; printing every new key to a terminal would put it + * in a scrollback buffer and a shell history by default. + */ + private int keygen(List operands) { + if (operands.size() != 1) { + return misuse("keygen "); + } + String alias = operands.getFirst(); + Identity identity = Identity.generateRandomIdentity(); + byte[] keyMaterial = identity.getPrivateKey().getRawData(); + try { + store.store(alias, keyMaterial); + output.println("Created identity '" + alias + "'"); + output.println(" public key: " + identity.getPublicKey().toBech32String()); + return 0; + } finally { + Arrays.fill(keyMaterial, (byte) 0); + } + } + + /** + * Imports an existing key read from standard input. + * + *

Read from stdin rather than an argument because arguments are visible in the host's + * process table, so a key passed that way is a key disclosed to every user on the machine. + */ + private int importKey(List operands) { + if (operands.size() != 1) { + return misuse("import (the key is read from standard input)"); + } + String alias = operands.getFirst(); + byte[] keyMaterial = readKeyFromStandardInput(); + try { + store.store(alias, keyMaterial); + output.println("Imported identity '" + alias + "'"); + output.println( + " public key: " + + Identity.create(new PrivateKey(keyMaterial)).getPublicKey().toBech32String()); + return 0; + } finally { + Arrays.fill(keyMaterial, (byte) 0); + } + } + + private int list() { + List aliases = store.aliases(); + if (aliases.isEmpty()) { + output.println("No identities yet. Create one with: keygen "); + return 0; + } + aliases.forEach(output::println); + return 0; + } + + /** + * Removes a key, saying plainly that it cannot be undone. + * + *

Reports whether anything was actually removed, since "remove a name that was already + * wrong" and "remove the account you meant to keep" look identical in a silent success. + */ + private int remove(List operands) { + if (operands.size() != 1) { + return misuse("remove "); + } + String alias = operands.getFirst(); + if (!store.remove(alias)) { + output.println("No identity called '" + alias + "'"); + return 1; + } + output.println("Removed identity '" + alias + "'. Without a backup this cannot be undone."); + return 0; + } + + private byte[] readKeyFromStandardInput() { + try { + String text = new String(System.in.readAllBytes()).trim(); + if (text.isEmpty()) { + throw new KeystoreException("No key was given on standard input"); + } + return text.startsWith(NSEC_PREFIX) ? decodeNsec(text) : HexFormat.of().parseHex(text); + } catch (IllegalArgumentException e) { + throw new KeystoreException("The key on standard input is neither hex nor an nsec", e); + } catch (java.io.IOException e) { + throw new KeystoreException("Could not read the key from standard input", e); + } + } + + /** + * Decodes the nsec form a person actually has. + * + *

{@code new PrivateKey(String)} parses hex only, so an nsec must be converted first rather + * than handed straight over: doing otherwise fails with a hex error on a perfectly valid key. + */ + private byte[] decodeNsec(String nsec) { + try { + return HexFormat.of().parseHex(Bech32.fromBech32(nsec)); + } catch (Exception e) { + throw new KeystoreException("The key on standard input is not a valid nsec", e); + } + } + + private int unknown(String command) { + output.println("Unknown command '" + command + "'"); + return usage(); + } + + private int misuse(String form) { + output.println("usage: " + form); + return 2; + } + + private int usage() { + output.println(""" + Manage the identities this MCP server signs with. + + usage: java -jar nostr-java-mcp.jar + + keygen create a new identity + import import a key read from standard input + list show the identities in the keystore + remove forget an identity + + With no command the MCP server starts and serves over stdio."""); + return 2; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/directory/Nip05Resolver.java b/nostr-java-mcp/src/main/java/nostr/mcp/directory/Nip05Resolver.java new file mode 100644 index 00000000..74e0cbfa --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/directory/Nip05Resolver.java @@ -0,0 +1,87 @@ +package nostr.mcp.directory; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** + * Turns a human-readable NIP-05 address into the public key behind it. + * + *

People know each other as {@code alice@example.com}, not as 64 hex characters, so an agent + * asked about someone by name has an address and needs a key. The SDK's {@code Nip05Validator} + * answers the opposite question, checking an address against a key already known, so resolution + * lives here. + * + *

Resolution is deliberately not verification. A domain's answer proves the domain claims that + * key, which is the whole guarantee NIP-05 offers, and callers should not read more into it. + */ +public final class Nip05Resolver { + + private static final String WELL_KNOWN_PATH = "/.well-known/nostr.json"; + private static final String IMPLIED_LOCAL_PART = "_"; + + private final WellKnownJson wellKnownJson; + + /** + * @param wellKnownJson fetches the domain's document + */ + public Nip05Resolver(@NonNull WellKnownJson wellKnownJson) { + this.wellKnownJson = wellKnownJson; + } + + /** + * Resolve an address to a public key. + * + * @param address a NIP-05 address such as {@code alice@example.com}, or a bare domain + * @return the public key in hex + * @throws nostr.mcp.tool.ToolException when the address is malformed or the domain has no + * record for it + */ + public String resolve(@NonNull String address) { + String normalised = address.trim().toLowerCase(Locale.ROOT); + String localPart = localPartOf(normalised); + String domain = domainOf(normalised); + JsonNode names = wellKnownJson.fetch(uriFor(domain, localPart), "application/json", address).path("names"); + JsonNode publicKey = names.path(localPart); + if (publicKey.isMissingNode() || !publicKey.isTextual()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + domain + " publishes no NIP-05 record for '" + localPart + "'"); + } + return publicKey.asText(); + } + + /** + * Reads the implied local part of a bare domain. + * + *

NIP-05 defines {@code example.com} as shorthand for {@code _@example.com}, and a user who + * types the short form means the long one. + */ + private String localPartOf(String address) { + int separator = address.indexOf('@'); + return separator < 0 ? IMPLIED_LOCAL_PART : address.substring(0, separator); + } + + private String domainOf(String address) { + int separator = address.indexOf('@'); + String domain = separator < 0 ? address : address.substring(separator + 1); + if (domain.isEmpty() || !domain.contains(".")) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + address + "' is not a NIP-05 address; expected something like alice@example.com"); + } + return domain; + } + + private URI uriFor(String domain, String localPart) { + return URI.create( + "https://" + + domain + + WELL_KNOWN_PATH + + "?name=" + + URLEncoder.encode(localPart, StandardCharsets.UTF_8)); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/directory/WellKnownJson.java b/nostr-java-mcp/src/main/java/nostr/mcp/directory/WellKnownJson.java new file mode 100644 index 00000000..4c530cb8 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/directory/WellKnownJson.java @@ -0,0 +1,82 @@ +package nostr.mcp.directory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * Fetches the small JSON documents Nostr keeps outside the relay protocol. + * + *

Both NIP-05 addresses and NIP-11 relay metadata are ordinary HTTPS documents, so they share + * one client, one timeout and one way of failing. Keeping that here means neither caller grows + * its own HTTP handling, and a network error becomes a code an agent can act on rather than an + * {@code IOException} surfacing as a stack trace. + */ +public final class WellKnownJson { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient httpClient; + + /** + * Uses a client that follows redirects, as both documents commonly do. + * + *

Pinned to HTTP/1.1. Java's client otherwise offers an HTTP/2 upgrade, and a Nostr relay + * serves its NIP-11 document from the same host and port as its websocket endpoint: it reads + * the upgrade headers as a botched websocket handshake and answers {@code 400 Failed to create + * websocket}. Observed against nostr-rs-relay, where curl succeeded and this client did not. + */ + public WellKnownJson() { + this( + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(TIMEOUT) + .build()); + } + + /** + * @param httpClient the client to fetch with, so a test need not reach the network + */ + public WellKnownJson(@NonNull HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Fetch and parse a JSON document. + * + * @param uri where the document lives + * @param accept the media type to request, since NIP-11 is served only when asked for + * @param describeTarget what to call the target in an error the agent reads + * @return the parsed document + * @throws nostr.mcp.tool.ToolException when it could not be fetched or was not JSON + */ + public JsonNode fetch(@NonNull URI uri, @NonNull String accept, @NonNull String describeTarget) { + HttpRequest request = + HttpRequest.newBuilder(uri).header("Accept", accept).timeout(TIMEOUT).GET().build(); + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw ToolFailure.RELAY_UNREACHABLE.raise( + describeTarget + " answered with HTTP " + response.statusCode()); + } + return MAPPER.readTree(response.body()); + } catch (IOException e) { + throw ToolFailure.RELAY_UNREACHABLE.raise( + "Could not reach " + describeTarget + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ToolFailure.TIMEOUT.raise("Interrupted while fetching " + describeTarget); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/guidance/ContextResources.java b/nostr-java-mcp/src/main/java/nostr/mcp/guidance/ContextResources.java new file mode 100644 index 00000000..f8466cf8 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/guidance/ContextResources.java @@ -0,0 +1,117 @@ +package nostr.mcp.guidance; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import lombok.NonNull; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.relay.RelayDirectory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Exposes the server's own configuration as readable resources. + * + *

An agent needs to know which identity it is and which relays it can reach before it does + * anything useful, and asking through a tool call spends a turn on something that never changes + * during a conversation. A host can read these once and put them in context, which is what + * resources are for. + * + *

They carry only what is already public: aliases, public keys and relay URIs. No private key + * appears here for the same reason it appears in no tool result. + */ +public final class ContextResources { + + private static final String IDENTITY_URI_PREFIX = "nostr://identity/"; + private static final String RELAY_URI_PREFIX = "nostr://relay/"; + private static final String MEDIA_TYPE = "application/json"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ContextResources() {} + + /** + * The resources describing this server's identities and relays. + * + * @param identityVault the identities this server holds + * @param relayDirectory the relays it is configured with + * @return the resource specifications to register + */ + public static List all( + @NonNull IdentityVault identityVault, @NonNull RelayDirectory relayDirectory) { + return List.of(identityResource(identityVault), relayResource(relayDirectory)); + } + + private static SyncResourceSpecification identityResource(IdentityVault identityVault) { + Resource resource = + Resource.builder() + .uri(IDENTITY_URI_PREFIX + "{alias}") + .name("Nostr identity") + .description("An identity this server can sign with: its alias, public key and npub.") + .mimeType(MEDIA_TYPE) + .build(); + return new SyncResourceSpecification( + resource, + (exchange, request) -> readIdentity(identityVault, request.uri())); + } + + private static ReadResourceResult readIdentity(IdentityVault identityVault, String uri) { + String alias = lastSegmentOf(uri); + return identityVault + .find(alias) + .map(summary -> contents(uri, describe(summary, identityVault))) + .orElseGet(() -> contents(uri, Map.of("error", "no identity called '" + alias + "'"))); + } + + private static Map describe(IdentitySummary summary, IdentityVault identityVault) { + Map described = new LinkedHashMap<>(); + described.put("alias", summary.alias()); + described.put("publicKey", summary.publicKey()); + described.put("npub", summary.npub()); + described.put("isDefault", identityVault.defaultAlias().map(summary.alias()::equals).orElse(false)); + return described; + } + + private static SyncResourceSpecification relayResource(RelayDirectory relayDirectory) { + Resource resource = + Resource.builder() + .uri(RELAY_URI_PREFIX + "{name}") + .name("Nostr relay") + .description("A relay this server is configured to use.") + .mimeType(MEDIA_TYPE) + .build(); + return new SyncResourceSpecification( + resource, (exchange, request) -> readRelay(relayDirectory, request.uri())); + } + + private static ReadResourceResult readRelay(RelayDirectory relayDirectory, String uri) { + String name = lastSegmentOf(uri); + List resolved = relayDirectory.resolve(List.of(name)); + return contents( + uri, + resolved.isEmpty() + ? Map.of("error", "no relay called '" + name + "'", "known", relayDirectory.names()) + : Map.of("name", name, "relays", resolved)); + } + + private static String lastSegmentOf(String uri) { + return uri.substring(uri.lastIndexOf('/') + 1); + } + + private static ReadResourceResult contents(String uri, Map fields) { + return new ReadResourceResult(List.of(new TextResourceContents(uri, MEDIA_TYPE, asJson(fields)))); + } + + private static String asJson(Map fields) { + try { + return MAPPER.writeValueAsString(fields); + } catch (JsonProcessingException e) { + return "{}"; + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/guidance/NostrPrompts.java b/nostr-java-mcp/src/main/java/nostr/mcp/guidance/NostrPrompts.java new file mode 100644 index 00000000..de81deeb --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/guidance/NostrPrompts.java @@ -0,0 +1,174 @@ +package nostr.mcp.guidance; + +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; + +import java.util.List; +import java.util.Map; + +/** + * Teaches a host how to sequence the tools. + * + *

A tool surface with no guidance makes an agent learn by trial and error, which on a public + * and irreversible medium is the wrong way to learn: the mistakes are permanent and other people + * see them. These prompts encode the orderings that work, so the common tasks do not have to be + * rediscovered by each model in each conversation. + * + *

Each is written as instructions to the agent rather than as a template the user fills in, + * because the failure being prevented is the agent choosing a wrong sequence, not the user + * phrasing a request badly. + */ +public final class NostrPrompts { + + private NostrPrompts() {} + + /** + * Every prompt this server offers. + * + * @return the prompt specifications to register + */ + public static List all() { + return List.of(composeNote(), catchUpFeed(), watchMentions()); + } + + /** + * Composing a note, with the confirmation step made explicit. + * + *

The step models most often get wrong is treating the preview as the publication, so this + * says plainly that the first call publishes nothing and that the user should see the text + * before the token is returned. + */ + private static SyncPromptSpecification composeNote() { + Prompt prompt = + new Prompt( + "compose-note", + "Write and publish a Nostr note", + "Draft a note, show it to the user, and publish it only once they agree.", + List.of(new PromptArgument("topic", "What the note should be about", true))); + + return new SyncPromptSpecification( + prompt, + (exchange, request) -> + result( + "Help the user publish a Nostr note about: " + + argument(request.arguments(), "topic") + + """ + . + + Follow this order: + + 1. Draft the note and show the user the exact text. Nostr notes are public \ + and cannot be reliably deleted, so they must see it before it goes out. + 2. Call nostr_publish_note with the content. If the server requires \ + confirmation, this publishes nothing: it returns a preview and a \ + confirmationToken. + 3. Show the user what came back and ask whether to go ahead. + 4. Only if they agree, call nostr_publish_note again with the same content \ + and the confirmationToken. + + Do not invent a confirmationToken. If you did not receive one, go back to \ + step 2. If the result reports that some relays refused the note, it is still \ + published: do not send it again.""")); + } + + /** + * Reading a feed, with the follow list as the starting point. + * + *

Models reach for a broad query and then try to filter it themselves, which returns + * strangers' notes and misses the people the user actually follows. + */ + private static SyncPromptSpecification catchUpFeed() { + Prompt prompt = + new Prompt( + "catch-up-feed", + "Summarise recent notes from the people you follow", + "Read the user's follow list and summarise what those accounts have posted.", + List.of(new PromptArgument("since", "How far back to look, such as '24h'", false))); + + return new SyncPromptSpecification( + prompt, + (exchange, request) -> + result( + """ + Summarise what the people the user follows have posted recently. + + Follow this order: + + 1. Call nostr_get_contacts with no arguments to read the user's own follow list. + 2. Call nostr_query_events with those public keys as `authors`, `kinds: [1]`, \ + and `since` set to """ + + argumentOr(request.arguments(), "since", "24h") + + """ + . + 3. Summarise the notes by theme rather than listing them one by one. + + Two things to watch for in the result. If `truncated` is true you did not \ + see everything, so say the summary is partial or narrow the time range. If \ + `timedOut` is true the relays did not finish answering, so do not report \ + "nothing happened" when the truth is that you stopped looking. + + If the user follows nobody, say so rather than querying every author on the \ + network.""")); + } + + /** + * Watching for mentions, with the asynchrony of subscriptions made explicit. + * + *

The mistake here is reading immediately and concluding nothing matched, when the relays + * have simply not finished replaying yet. + */ + private static SyncPromptSpecification watchMentions() { + Prompt prompt = + new Prompt( + "watch-mentions", + "Watch for new mentions of the user", + "Open a subscription for mentions and report them as they arrive.", + List.of()); + + return new SyncPromptSpecification( + prompt, + (exchange, request) -> + result( + """ + Watch for new notes that mention the user. + + Follow this order: + + 1. Call nostr_list_identities to find the user's public key. + 2. Call nostr_subscribe with `kinds: [1]` and a `p` tag filter naming that \ + public key, so you receive notes that mention them. + 3. Note the subscriptionId that comes back. + 4. Call nostr_read_subscription with that id whenever you want to check. + + A subscription does not answer immediately. When you first read it the relays \ + may still be replaying their stored events, and the result says so with \ + `backlogDrained`. An empty read with `backlogDrained: false` means "not yet", \ + not "nothing mentions you". Do not report the second when the first is true. + + If a read reports a `droppedCount` above zero, the buffer overflowed and you \ + have missed some mentions: tell the user rather than presenting what you have \ + as the complete picture. + + Call nostr_unsubscribe when the user is done, so the subscription is not left \ + open.""")); + } + + private static GetPromptResult result(String instructions) { + return new GetPromptResult( + null, List.of(new PromptMessage(Role.USER, new TextContent(instructions)))); + } + + private static String argument(Map arguments, String name) { + return argumentOr(arguments, name, ""); + } + + private static String argumentOr(Map arguments, String name, String fallback) { + Object value = arguments == null ? null : arguments.get(name); + return value == null || String.valueOf(value).isBlank() ? fallback : String.valueOf(value); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/AliasIndex.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/AliasIndex.java new file mode 100644 index 00000000..92345d7c --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/AliasIndex.java @@ -0,0 +1,91 @@ +package nostr.mcp.identity; + +import lombok.NonNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Remembers which aliases exist in a store that cannot enumerate itself. + * + *

An OS keychain answers "give me the secret for this name" but not "what names are there", + * so without a record of the names a key created by the CLI would be invisible to the server + * that needs it. This file is that record. + * + *

It holds names only, never key material, so it needs no passphrase and no special + * permissions. Losing it loses the ability to list, never the ability to sign: naming the alias + * explicitly still works, which is why it is a convenience index rather than a source of truth. + */ +public final class AliasIndex { + + private final Path indexPath; + + /** + * @param indexPath the file recording the aliases + */ + public AliasIndex(@NonNull Path indexPath) { + this.indexPath = indexPath; + } + + /** + * The recorded aliases. + * + * @return the aliases in the order they were added, empty when nothing was recorded + */ + public List read() { + if (!Files.exists(indexPath)) { + return List.of(); + } + try { + return Files.readAllLines(indexPath, StandardCharsets.UTF_8).stream() + .map(String::trim) + .filter(line -> !line.isEmpty()) + .distinct() + .toList(); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the alias index at " + indexPath, e); + } + } + + /** + * Record an alias, ignoring one already present. + * + * @param alias the alias to remember + */ + public void add(@NonNull String alias) { + Set aliases = new LinkedHashSet<>(read()); + if (aliases.add(alias)) { + write(aliases); + } + } + + /** + * Forget an alias. + * + * @param alias the alias to remove + */ + public void remove(@NonNull String alias) { + Set aliases = new LinkedHashSet<>(read()); + if (aliases.remove(alias)) { + write(aliases); + } + } + + private void write(Set aliases) { + try { + Path parent = indexPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(indexPath, String.join("\n", aliases) + "\n", StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Could not write the alias index at " + indexPath, e); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/EncryptedFileKeySource.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/EncryptedFileKeySource.java new file mode 100644 index 00000000..d21321fd --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/EncryptedFileKeySource.java @@ -0,0 +1,285 @@ +package nostr.mcp.identity; + +import lombok.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableEntryException; +import java.security.cert.CertificateException; +import java.util.Arrays; +import java.util.Collections; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +/** + * Keys in a passphrase-protected keystore file. + * + *

The portable option, and the right one in a container: it needs no platform service, and it + * survives a restart through a mounted volume. It is also the only backend a deployment fully + * controls, which is why it carries the permission check. + * + *

A world-readable keystore is refused rather than warned about. The passphrase is the only + * thing protecting the file, and a file every user on the host can copy is one an attacker can + * work on offline for as long as they like. + */ +public final class EncryptedFileKeySource implements KeySource, IdentityStore { + + /** The value that selects this backend. */ + public static final String TYPE = "encrypted-file"; + + /** + * PKCS#12 stores a secret key under a named algorithm and rejects one it does not recognise, + * so a nostr key travels as an AES key of the same 32 bytes. The name is a container label + * here, not a statement about how the key is used: nothing ever asks this keystore to encrypt. + */ + private static final String KEY_ALGORITHM = "AES"; + private static final Set FORBIDDEN_PERMISSIONS = + Set.of( + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_WRITE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_WRITE); + + private final Path keystorePath; + private final char[] passphrase; + + /** + * @param keystorePath the keystore file + * @param passphrase the passphrase protecting it + */ + public EncryptedFileKeySource(@NonNull Path keystorePath, @NonNull char[] passphrase) { + this.keystorePath = keystorePath; + this.passphrase = passphrase.clone(); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public Map loadKeys(IdentityBinding binding) { + if (!Files.exists(keystorePath)) { + return Map.of(); + } + refuseIfReadableByOthers(); + try (InputStream keystoreStream = Files.newInputStream(keystorePath)) { + return readEntries(loadKeystore(keystoreStream), binding); + } catch (IOException e) { + throw new KeystoreException("Could not read the keystore at " + keystorePath, e); + } + } + + /** + * Write a key into the keystore, creating it if needed. + * + *

Belongs here rather than in a separate writer because the file's permissions and its + * passphrase are the same concern as reading it, and splitting them invites a writer that + * creates the very world-readable file the reader refuses. + * + * @param alias the name to store it under + * @param keyMaterial the private key bytes + */ + @Override + public void store(@NonNull String alias, @NonNull byte[] keyMaterial) { + try { + KeyStore keystore = openOrCreate(); + refuseIfAliasTaken(keystore, alias); + keystore.setEntry( + alias, + new KeyStore.SecretKeyEntry(new SecretKeySpec(keyMaterial, KEY_ALGORITHM)), + new KeyStore.PasswordProtection(passphrase)); + writeOwnerOnly(keystore); + } catch (IOException | KeyStoreException | NoSuchAlgorithmException | CertificateException e) { + throw new KeystoreException("Could not store the key for '" + alias + "'", e); + } + } + + @Override + public List aliases() { + if (!Files.exists(keystorePath)) { + return List.of(); + } + refuseIfReadableByOthers(); + try (InputStream keystoreStream = Files.newInputStream(keystorePath)) { + return Collections.list(loadKeystore(keystoreStream).aliases()); + } catch (IOException | KeyStoreException e) { + throw new KeystoreException("Could not list the keystore at " + keystorePath, e); + } + } + + @Override + public boolean remove(@NonNull String alias) { + if (!Files.exists(keystorePath)) { + return false; + } + try { + KeyStore keystore = openOrCreate(); + if (!keystore.containsAlias(alias)) { + return false; + } + keystore.deleteEntry(alias); + writeOwnerOnly(keystore); + return true; + } catch (IOException | KeyStoreException | NoSuchAlgorithmException | CertificateException e) { + throw new KeystoreException("Could not remove the key for '" + alias + "'", e); + } + } + + /** + * Refuses to overwrite an existing entry. + * + *

Silently replacing a key destroys an account with no warning and no way back, so the + * caller is told to remove it deliberately first. + */ + private void refuseIfAliasTaken(KeyStore keystore, String alias) throws KeyStoreException { + if (keystore.containsAlias(alias)) { + throw new KeystoreException( + "The keystore already holds an identity called '" + + alias + + "'; remove it first if you really mean to replace it"); + } + } + + private KeyStore openOrCreate() throws KeyStoreException, IOException, NoSuchAlgorithmException, + CertificateException { + KeyStore keystore = KeyStore.getInstance("PKCS12"); + if (Files.exists(keystorePath)) { + try (InputStream keystoreStream = Files.newInputStream(keystorePath)) { + keystore.load(keystoreStream, passphrase); + } + } else { + keystore.load(null, passphrase); + } + return keystore; + } + + private void writeOwnerOnly(KeyStore keystore) throws IOException, KeyStoreException, + NoSuchAlgorithmException, CertificateException { + Path parent = keystorePath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try (OutputStream keystoreStream = Files.newOutputStream(keystorePath)) { + keystore.store(keystoreStream, passphrase); + } + restrictToOwner(); + } + + private void restrictToOwner() throws IOException { + if (supportsPosixPermissions()) { + Files.setPosixFilePermissions( + keystorePath, + Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } + } + + private KeyStore loadKeystore(InputStream keystoreStream) { + try { + KeyStore keystore = KeyStore.getInstance("PKCS12"); + keystore.load(keystoreStream, passphrase); + return keystore; + } catch (IOException e) { + throw new KeystoreException( + "Could not open the keystore at " + keystorePath + "; the passphrase may be wrong", e); + } catch (NoSuchAlgorithmException | CertificateException | KeyStoreException e) { + throw new KeystoreException("Could not open the keystore at " + keystorePath, e); + } + } + + /** + * Decrypts only the permitted entries. + * + *

Listing aliases does not decrypt anything, so a bound process can see which entries exist + * and still read only its own. That ordering is the guarantee: the other keys stay ciphertext. + */ + private Map readEntries(KeyStore keystore, IdentityBinding binding) { + Map keys = new LinkedHashMap<>(); + try { + Set permitted = binding.permitted(new LinkedHashSet<>(Collections.list(keystore.aliases()))); + for (String alias : Collections.list(keystore.aliases())) { + if (permitted.contains(alias)) { + keys.put(alias, readEntry(keystore, alias)); + } + } + } catch (KeyStoreException e) { + throw new KeystoreException("Could not list the keystore entries", e); + } + return keys; + } + + private byte[] readEntry(KeyStore keystore, String alias) { + try { + KeyStore.Entry entry = + keystore.getEntry(alias, new KeyStore.PasswordProtection(passphrase)); + if (!(entry instanceof KeyStore.SecretKeyEntry secretKeyEntry)) { + throw new KeystoreException("Keystore entry '" + alias + "' is not a private key"); + } + SecretKey key = secretKeyEntry.getSecretKey(); + return decodeIfHex(key.getEncoded()); + } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableEntryException e) { + throw new KeystoreException("Could not read the keystore entry '" + alias + "'", e); + } + } + + /** + * Accepts a key stored either as raw bytes or as the hex text a person would paste. + * + *

A keystore written by the CLI holds raw bytes, but one a user populated by hand may hold + * the hex form. Failing on the second would be a confusing way to say "wrong format". + */ + private byte[] decodeIfHex(byte[] stored) { + if (stored.length != 64) { + return stored; + } + try { + return HexFormat.of().parseHex(new String(stored, StandardCharsets.US_ASCII)); + } catch (IllegalArgumentException notHex) { + return stored; + } + } + + private void refuseIfReadableByOthers() { + if (!supportsPosixPermissions()) { + return; + } + try { + Set permissions = Files.getPosixFilePermissions(keystorePath); + Set exposed = new LinkedHashSet<>(permissions); + exposed.retainAll(FORBIDDEN_PERMISSIONS); + if (!exposed.isEmpty()) { + throw new KeystoreException( + "Refusing to read the keystore at " + + keystorePath + + " because it is readable beyond its owner (" + + exposed + + "); run chmod 600 on it"); + } + } catch (IOException e) { + throw new KeystoreException("Could not check the keystore's permissions", e); + } + } + + private boolean supportsPosixPermissions() { + return keystorePath.getFileSystem().supportedFileAttributeViews().contains("posix"); + } + + /** Wipes the passphrase copy this source holds. */ + public void close() { + Arrays.fill(passphrase, '\0'); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/EnvironmentKeySource.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/EnvironmentKeySource.java new file mode 100644 index 00000000..ff1b3ba1 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/EnvironmentKeySource.java @@ -0,0 +1,80 @@ +package nostr.mcp.identity; + +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Keys read from environment variables, for development only. + * + *

Zero setup and it works anywhere, which is exactly why it is tempting and why it warns. A + * process environment is readable by child processes, appears in {@code /proc} on Linux, and is + * captured wholesale by most crash reporters, so a key here should be one nobody minds losing. + * + *

Variables are named {@code NOSTR_MCP_KEY_}, so {@code NOSTR_MCP_KEY_PERSONAL} + * becomes the alias {@code personal}. + */ +public final class EnvironmentKeySource implements KeySource { + + /** The value that selects this backend. */ + public static final String TYPE = "env"; + + private static final String PREFIX = "NOSTR_MCP_KEY_"; + + private final Supplier> environment; + + /** Reads the process environment. */ + public EnvironmentKeySource() { + this(System::getenv); + } + + /** + * @param environment the variables to read, so a test need not mutate the process environment + */ + public EnvironmentKeySource(Supplier> environment) { + this.environment = environment; + } + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean protectsKeysAtRest() { + return false; + } + + @Override + public Map loadKeys(IdentityBinding binding) { + Map keys = new LinkedHashMap<>(); + environment + .get() + .forEach( + (name, value) -> { + if (name.startsWith(PREFIX) && !value.isBlank()) { + String alias = aliasOf(name); + if (binding.permitted(Set.of(alias)).contains(alias)) { + keys.put(alias, decode(name, value)); + } + } + }); + return keys; + } + + private String aliasOf(String variableName) { + return variableName.substring(PREFIX.length()).toLowerCase().replace('_', '-'); + } + + private byte[] decode(String variableName, String value) { + try { + return HexFormat.of().parseHex(value.trim()); + } catch (IllegalArgumentException e) { + throw new KeystoreException( + variableName + " does not hold a hex private key", e); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityAlias.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityAlias.java new file mode 100644 index 00000000..7c5ad972 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityAlias.java @@ -0,0 +1,40 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.util.regex.Pattern; + +/** + * The name a person gives one of their identities. + * + *

Aliases appear in resource URIs such as {@code nostr://identity/personal}, so they are + * restricted to characters that survive a URI unescaped. Validating on the way in means the rest + * of the module can treat an alias as a plain path segment rather than escaping it at every use, + * and it rules out an alias whose slashes or spaces would silently address something else. + */ +public final class IdentityAlias { + + private static final Pattern PERMITTED = Pattern.compile("[a-z0-9-]{1,32}"); + + private IdentityAlias() {} + + /** + * Check an alias, explaining the rule when it fails. + * + * @param alias the proposed name + * @return the alias, unchanged + * @throws nostr.mcp.tool.ToolException when it would not be safe in a URI + */ + public static String validated(@NonNull String alias) { + String trimmed = alias.trim(); + if (!PERMITTED.matcher(trimmed).matches()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + + alias + + "' is not a usable alias. Use 1 to 32 characters of lowercase letters, digits and" + + " hyphens, such as 'project-bot'."); + } + return trimmed; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityBinding.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityBinding.java new file mode 100644 index 00000000..ec03a3ef --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityBinding.java @@ -0,0 +1,94 @@ +package nostr.mcp.identity; + +import lombok.NonNull; + +import java.util.Optional; +import java.util.Set; + +/** + * Which identity a server process operates. + * + *

A process is either bound to exactly one alias or unbound and able to sign + * as any identity its keystore holds. The distinction is not a preference, it is the module's + * strongest isolation guarantee: a bound process never decrypts the other entries, so another + * identity's key is absent from its heap rather than merely out of policy. + * + *

Binding also removes a class of mistake instead of guarding against it. With one identity + * the {@code identity} argument disappears from every signing tool, so an agent cannot name the + * wrong account, because there is no name to give. + */ +public final class IdentityBinding { + + private static final IdentityBinding UNBOUND = new IdentityBinding(null); + + private final String alias; + + private IdentityBinding(String alias) { + this.alias = alias; + } + + /** + * A process that may sign as any identity in its keystore. + * + * @return the unbound binding + */ + public static IdentityBinding unbound() { + return UNBOUND; + } + + /** + * A process bound to one identity. + * + * @param alias the only identity this process may operate + * @return the binding + */ + public static IdentityBinding to(@NonNull String alias) { + return new IdentityBinding(alias); + } + + /** + * Read a binding from configuration, where absent means unbound. + * + * @param alias the configured alias, or {@code null} or blank when none is set + * @return the binding the configuration describes + */ + public static IdentityBinding fromConfiguredAlias(String alias) { + return alias == null || alias.isBlank() ? unbound() : to(alias); + } + + /** + * Whether this process is restricted to a single identity. + * + * @return true when bound + */ + public boolean isBound() { + return alias != null; + } + + /** + * The bound alias. + * + * @return the alias, or empty when unbound + */ + public Optional alias() { + return Optional.ofNullable(alias); + } + + /** + * Narrow a set of candidate aliases to those this process may unlock. + * + *

Applied before decryption rather than after, so a bound process's restriction is enforced + * by never reading the other keys instead of by discarding them once read. + * + * @param candidates the aliases the keystore offers + * @return every candidate when unbound, otherwise only the bound alias + */ + public Set permitted(@NonNull Set candidates) { + return isBound() ? Set.of(alias) : candidates; + } + + @Override + public String toString() { + return isBound() ? "bound to '" + alias + "'" : "unbound"; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityLifecycle.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityLifecycle.java new file mode 100644 index 00000000..66c42a21 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityLifecycle.java @@ -0,0 +1,238 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.base.PrivateKey; +import nostr.id.Identity; +import nostr.mcp.tool.ToolFailure; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.KeyStore; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.crypto.spec.SecretKeySpec; + +/** + * Creating, importing, renaming, exporting and destroying identities. + * + *

The vault holds keys for the running process and the store holds them across restarts, so + * every lifecycle change has to touch both or leave the server disagreeing with its own keystore + * after a restart. Doing that in one place is what keeps the two consistent, and it gives the + * tools a vocabulary in terms of the lifecycle rather than of storage. + * + *

Which backups exist is tracked here too, because removal depends on it: destroying a key + * that was never backed up is the one action in this module with no remedy at all. + */ +@Slf4j +public final class IdentityLifecycle { + + private static final String KEY_ALGORITHM = "AES"; + private static final Set OWNER_ONLY = + Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private final IdentityVault vault; + private final IdentityStore store; + private final Set aliasesWithBackups = new HashSet<>(); + + /** + * @param vault the keys this process holds + * @param store where keys persist across restarts + */ + public IdentityLifecycle(@NonNull IdentityVault vault, @NonNull IdentityStore store) { + this.vault = vault; + this.store = store; + } + + /** + * Generate a new identity. + * + * @param alias the name to file it under + * @return the new identity's public details + * @throws nostr.mcp.tool.ToolException when the alias is taken or unusable + */ + public IdentitySummary create(@NonNull String alias) { + String validated = IdentityAlias.validated(alias); + byte[] keyMaterial = randomKeyMaterial(); + try { + persist(validated, keyMaterial); + return summaryOf(validated); + } finally { + Arrays.fill(keyMaterial, (byte) 0); + } + } + + /** + * Import an existing key from wherever the source names. + * + * @param alias the name to file it under + * @param source where the server should read the key from + * @return the imported identity's public details + * @throws nostr.mcp.tool.ToolException when the alias is taken or the source is unreadable + */ + public IdentitySummary importFrom(@NonNull String alias, @NonNull String source) { + String validated = IdentityAlias.validated(alias); + byte[] keyMaterial = KeyImportSource.read(source); + try { + persist(validated, keyMaterial); + return summaryOf(validated); + } finally { + Arrays.fill(keyMaterial, (byte) 0); + } + } + + /** + * Rename an identity in both the vault and the store. + * + * @param currentAlias the existing name + * @param newAlias the name to use instead + * @return the renamed identity's public details + */ + public IdentitySummary rename(@NonNull String currentAlias, @NonNull String newAlias) { + String validated = IdentityAlias.validated(newAlias); + byte[] keyMaterial = keyMaterialFor(currentAlias); + try { + store.store(validated, keyMaterial); + store.remove(currentAlias); + vault.rename(currentAlias, validated); + moveBackupRecord(currentAlias, validated); + return summaryOf(validated); + } catch (KeystoreException e) { + throw ToolFailure.INVALID_ARGUMENT.raise(e.getMessage()); + } finally { + Arrays.fill(keyMaterial, (byte) 0); + } + } + + /** + * Write a passphrase-encrypted backup and report where it went. + * + * @param alias the identity to back up + * @param destination where to write the file + * @param passphrase what protects it + * @return the path written + * @throws nostr.mcp.tool.ToolException when the file could not be written + */ + public Path exportBackup(@NonNull String alias, @NonNull Path destination, @NonNull char[] passphrase) { + byte[] keyMaterial = keyMaterialFor(alias); + try { + writeEncryptedBackup(alias, destination, passphrase, keyMaterial); + aliasesWithBackups.add(alias); + log.info( + "Exported a backup of identity '{}' ({}) to {}", + alias, + vault.publicKeyOf(alias).toBech32String(), + destination); + return destination; + } finally { + Arrays.fill(keyMaterial, (byte) 0); + Arrays.fill(passphrase, '\0'); + } + } + + /** + * Destroy an identity, in memory and on disk. + * + * @param alias the identity to remove + */ + public void remove(@NonNull String alias) { + vault.remove(alias); + store.remove(alias); + aliasesWithBackups.remove(alias); + } + + /** + * Whether a backup of this identity has been exported since the server started. + * + *

Deliberately not persisted. A record claiming a backup exists is worthless unless the file + * still does, and this server cannot know that after a restart; the honest answer is then "no + * backup I know of", which makes removal ask for an explicit acknowledgement rather than + * relying on a stale reassurance. + * + * @param alias the identity to check + * @return true when a backup was taken in this session + */ + public boolean hasBackup(@NonNull String alias) { + return aliasesWithBackups.contains(alias); + } + + private void persist(String alias, byte[] keyMaterial) { + try { + store.store(alias, keyMaterial); + } catch (KeystoreException e) { + throw ToolFailure.INVALID_ARGUMENT.raise(e.getMessage()); + } + try { + vault.add(alias, keyMaterial.clone()); + } catch (KeystoreException e) { + store.remove(alias); + throw ToolFailure.INVALID_ARGUMENT.raise(e.getMessage()); + } + } + + /** + * Recovers the key material for an identity the vault already holds. + * + *

Derived from the vault rather than read back from the store, so this works for a backend + * that cannot re-read what it wrote, and so an export never depends on the keystore being + * readable a second time. + */ + private byte[] keyMaterialFor(String alias) { + return vault.exportKeyMaterial(alias); + } + + private byte[] randomKeyMaterial() { + return java.util.HexFormat.of() + .parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + } + + private IdentitySummary summaryOf(String alias) { + return vault.find(alias).orElseThrow(() -> new IdentityUnknownException(alias, Set.of())); + } + + private void moveBackupRecord(String currentAlias, String newAlias) { + if (aliasesWithBackups.remove(currentAlias)) { + aliasesWithBackups.add(newAlias); + } + } + + /** + * Writes the backup as a keystore of its own, so restoring it needs no bespoke format. + * + *

Owner-only permissions are set before anything sensitive is written, since a backup + * readable by other users is a copy of the key an attacker can work on offline. + */ + private void writeEncryptedBackup( + String alias, Path destination, char[] passphrase, byte[] keyMaterial) { + try { + Path parent = destination.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + KeyStore backup = KeyStore.getInstance("PKCS12"); + backup.load(null, passphrase); + backup.setEntry( + alias, + new KeyStore.SecretKeyEntry(new SecretKeySpec(keyMaterial, KEY_ALGORITHM)), + new KeyStore.PasswordProtection(passphrase)); + try (OutputStream file = Files.newOutputStream(destination)) { + restrictToOwner(destination); + backup.store(file, passphrase); + } + restrictToOwner(destination); + } catch (Exception e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "Could not write the backup to " + destination + ": " + e.getMessage()); + } + } + + private void restrictToOwner(Path file) throws IOException { + if (file.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(file, OWNER_ONLY); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityPolicy.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityPolicy.java new file mode 100644 index 00000000..21f8146e --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityPolicy.java @@ -0,0 +1,82 @@ +package nostr.mcp.identity; + +import nostr.mcp.write.WritePolicy; + +import java.util.Locale; + +/** + * How much freedom an agent has to change the keystore. + * + *

Separate from the write policy because the two risks are different in kind. Publishing is + * public and irreversible but bounded to one event; destroying a key ends an account and orphans + * every event ever signed with it. A deployment may reasonably let an agent post while forbidding + * it to touch the keys, and collapsing the two would force a choice between a useless server and + * an unsafe one. + */ +public enum IdentityPolicy { + + /** Keystore-mutating tools are not registered. */ + DENY, + + /** Irreversible mutations need a second call carrying a token. */ + CONFIRM, + + /** Mutations proceed directly, for automation that provisions its own identities. */ + ALLOW; + + /** + * Read the configured policy, never granting more than the write policy does. + * + *

A read-only server must not be able to destroy a key, so {@code write-policy: deny} + * implies no mutation whatever this setting says. Defaulting to the more restrictive of the two + * means the safe combination needs no configuration, which is the one people get right. + * + * @param configured the value from configuration, which may be null + * @param writePolicy the write policy, which caps this one + * @return the policy to enforce + */ + public static IdentityPolicy fromConfiguredValue(String configured, WritePolicy writePolicy) { + if (writePolicy == WritePolicy.DENY) { + return DENY; + } + IdentityPolicy requested = parse(configured); + return requested.isMorePermissiveThan(writePolicy) ? matching(writePolicy) : requested; + } + + /** + * Whether keystore-mutating tools appear on the surface at all. + * + * @return true unless mutation is denied + */ + public boolean allowsMutation() { + return this != DENY; + } + + /** + * Whether an irreversible mutation needs a second call. + * + * @return true when confirmation is required + */ + public boolean requiresConfirmation() { + return this != ALLOW; + } + + private static IdentityPolicy parse(String configured) { + if (configured == null || configured.isBlank()) { + return CONFIRM; + } + try { + return valueOf(configured.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException unrecognised) { + return CONFIRM; + } + } + + private boolean isMorePermissiveThan(WritePolicy writePolicy) { + return this == ALLOW && writePolicy == WritePolicy.CONFIRM; + } + + private static IdentityPolicy matching(WritePolicy writePolicy) { + return writePolicy == WritePolicy.ALLOW ? ALLOW : CONFIRM; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityStore.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityStore.java new file mode 100644 index 00000000..24f2867e --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityStore.java @@ -0,0 +1,42 @@ +package nostr.mcp.identity; + +import java.util.List; + +/** + * Creating, importing and removing keys. + * + *

Separate from {@link KeySource} because reading keys and administering them are different + * privileges held by different actors: every server reads, only a human administers. A backend + * that cannot write, such as a future remote signer, implements the source alone and is not + * silently broken by a lifecycle it cannot honour. + * + *

The CLI and the identity lifecycle tools both drive this interface, so there is one + * implementation of what "remove an identity" means and two front doors to it. + */ +public interface IdentityStore { + + /** + * Store a key under an alias. + * + * @param alias the name to file it under + * @param keyMaterial the private key bytes, which the caller owns and wipes + * @throws KeystoreException when the alias is taken or the store could not be written + */ + void store(String alias, byte[] keyMaterial); + + /** + * The aliases this store holds, without decrypting anything. + * + * @return the known aliases, in insertion order where the backend preserves it + */ + List aliases(); + + /** + * Forget a key. + * + * @param alias the identity to remove + * @return true when an entry was removed, false when there was none + * @throws KeystoreException when the store could not be written + */ + boolean remove(String alias); +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentitySummary.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentitySummary.java new file mode 100644 index 00000000..0ea67220 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentitySummary.java @@ -0,0 +1,30 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import nostr.base.PublicKey; + +/** + * What an agent is allowed to learn about an identity. + * + *

This type is the enforcement mechanism for "no tool can return a private key", not a + * convenience. It has no field capable of holding key material, so a tool cannot leak one by + * accident, by refactoring, or by a future author not knowing the rule. Making the guarantee a + * property of the type means it survives people. + * + * @param alias the human-meaningful name this identity is known by + * @param publicKey the identity's public key, in hex + * @param npub the same key in bech32 form, which is what a user recognises + */ +public record IdentitySummary(String alias, String publicKey, String npub) { + + /** + * Describe an identity by its alias and public key. + * + * @param alias the name this identity is known by + * @param publicKey the public key to report + * @return the summary an agent may see + */ + public static IdentitySummary of(@NonNull String alias, @NonNull PublicKey publicKey) { + return new IdentitySummary(alias, publicKey.toHexString(), publicKey.toBech32String()); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityUnknownException.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityUnknownException.java new file mode 100644 index 00000000..51485404 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityUnknownException.java @@ -0,0 +1,37 @@ +package nostr.mcp.identity; + +import java.util.Collection; +import java.util.List; + +/** + * Thrown when a caller names an identity the vault does not hold. + * + *

Lists the aliases that do exist, because an agent that named the wrong one can correct + * itself from that and cannot from "unknown identity". The public keys are not listed: knowing + * which aliases exist is navigation, and knowing whose keys they are is disclosure. + */ +public class IdentityUnknownException extends RuntimeException { + + private final transient Collection knownAliases; + + /** + * @param alias the alias that was asked for + * @param knownAliases the aliases the vault does hold + */ + public IdentityUnknownException(String alias, Collection knownAliases) { + super( + knownAliases.isEmpty() + ? "No identity named '" + alias + "'; the keystore is empty" + : "No identity named '" + alias + "'; known aliases are " + knownAliases); + this.knownAliases = List.copyOf(knownAliases); + } + + /** + * The aliases the vault holds. + * + * @return the known aliases + */ + public Collection getKnownAliases() { + return knownAliases; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityVault.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityVault.java new file mode 100644 index 00000000..40ff79b8 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/IdentityVault.java @@ -0,0 +1,342 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.base.ISignable; +import nostr.base.PrivateKey; +import nostr.base.PublicKey; +import nostr.id.Identity; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Holds the signing keys and does the signing, so nothing above it needs a key. + * + *

This is the module's central security boundary. Tools name an identity and receive a signed + * event; they never receive an {@link Identity}, because an object that can sign is an object + * that can be made to sign anything, and a tool layer that holds one is one bug away from + * leaking it into a result. + * + *

Keys are wiped on shutdown. That is worth doing even though it cannot be complete: the SDK + * derives its own copies internally, so wiping the vault's arrays shortens the window rather + * than closing it. Claiming more than that would be dishonest, but a shorter window is still + * worth having in a process an agent drives. + */ +@Slf4j +public final class IdentityVault implements AutoCloseable { + + private final Map identitiesByAlias = new LinkedHashMap<>(); + private final Map keyMaterialByAlias = new LinkedHashMap<>(); + private final IdentityBinding binding; + private volatile String defaultAlias; + + /** + * Unlock every key the source holds, for a process that may sign as any of them. + * + * @param keySource where the keys come from + * @param defaultAlias the identity used when a caller names none, or {@code null} for none + */ + public IdentityVault(@NonNull KeySource keySource, String defaultAlias) { + this(keySource, defaultAlias, IdentityBinding.unbound()); + } + + /** + * Unlock the keys this process is permitted to hold. + * + *

A bound process refuses to start when its alias is missing. The alternative is a server + * that runs, advertises signing tools, and fails at the moment an agent tries to use one; a + * misconfigured binding is a startup problem and is reported where it can be fixed. + * + * @param keySource where the keys come from + * @param defaultAlias the identity used when a caller names none, or {@code null} for none + * @param binding which identities this process may operate + * @throws IdentityUnknownException when a bound process's alias is not in the keystore + */ + public IdentityVault( + @NonNull KeySource keySource, String defaultAlias, @NonNull IdentityBinding binding) { + this.binding = binding; + warnIfUnprotected(keySource); + keySource.loadKeys(binding).forEach(this::unlock); + requireBoundIdentityWasFound(keySource); + this.defaultAlias = resolveDefault(binding.alias().orElse(defaultAlias)); + announce(keySource); + } + + /** + * How this process is restricted, if at all. + * + * @return the binding this vault was built with + */ + public IdentityBinding binding() { + return binding; + } + + /** + * Fails a bound process whose identity does not exist, naming how to create one. + * + *

Only bound processes refuse. An unbound server with an empty keystore is how a person + * creates their first key, so refusing there would make the module impossible to bootstrap. + */ + private void requireBoundIdentityWasFound(KeySource keySource) { + binding + .alias() + .filter(alias -> !identitiesByAlias.containsKey(alias)) + .ifPresent( + alias -> { + throw new KeystoreException( + "This server is bound to identity '" + + alias + + "', which the " + + keySource.type() + + " keystore does not hold. Create it with: java -jar nostr-java-mcp.jar" + + " keygen " + + alias); + }); + } + + /** + * The identities this vault can sign with, as an agent may see them. + * + * @return one summary per identity, carrying no key material + */ + public List list() { + return identitiesByAlias.entrySet().stream() + .map(entry -> IdentitySummary.of(entry.getKey(), entry.getValue().getPublicKey())) + .toList(); + } + + /** + * Look up one identity's public details. + * + * @param alias the identity to describe + * @return its summary, or empty when the vault holds no such alias + */ + public Optional find(@NonNull String alias) { + return Optional.ofNullable(identitiesByAlias.get(alias)) + .map(identity -> IdentitySummary.of(alias, identity.getPublicKey())); + } + + /** + * The alias used when a caller names none. + * + * @return the default alias, or empty when the vault holds none or several without a choice + */ + public Optional defaultAlias() { + return Optional.ofNullable(defaultAlias); + } + + /** + * The public key of an identity, for addressing rather than signing. + * + * @param alias the identity to look up + * @return its public key + * @throws IdentityUnknownException when the vault holds no such alias + */ + public PublicKey publicKeyOf(@NonNull String alias) { + return require(alias).getPublicKey(); + } + + /** + * Sign something as the named identity. + * + *

The only capability that crosses this boundary. A caller gets a signature, never the key + * that produced it. + * + * @param alias the identity to sign as + * @param signable what to sign + * @throws IdentityUnknownException when the vault holds no such alias + */ + public void signAs(@NonNull String alias, @NonNull ISignable signable) { + require(alias).sign(signable); + } + + /** + * Take an identity into the vault, which becomes its owner. + * + *

The caller hands over the key material and must not keep it: the vault wipes what it + * holds on shutdown, and a second copy elsewhere would outlive that. + * + * @param alias the name to hold it under + * @param keyMaterial the private key bytes + * @throws KeystoreException when the alias is already taken + */ + public synchronized void add(@NonNull String alias, @NonNull byte[] keyMaterial) { + if (identitiesByAlias.containsKey(alias)) { + throw new KeystoreException("This server already holds an identity called '" + alias + "'"); + } + unlock(alias, keyMaterial); + if (defaultAlias == null && identitiesByAlias.size() == 1) { + defaultAlias = alias; + } + log.info("Added identity '{}' ({})", alias, identitiesByAlias.get(alias).getPublicKey().toBech32String()); + } + + /** + * Change what an identity is called, keeping the same key. + * + * @param currentAlias the existing name + * @param newAlias the name to use instead + * @throws IdentityUnknownException when the current alias is not held + * @throws KeystoreException when the new alias is taken + */ + public synchronized void rename(@NonNull String currentAlias, @NonNull String newAlias) { + Identity identity = require(currentAlias); + if (identitiesByAlias.containsKey(newAlias)) { + throw new KeystoreException("This server already holds an identity called '" + newAlias + "'"); + } + identitiesByAlias.remove(currentAlias); + identitiesByAlias.put(newAlias, identity); + keyMaterialByAlias.put(newAlias, keyMaterialByAlias.remove(currentAlias)); + if (currentAlias.equals(defaultAlias)) { + defaultAlias = newAlias; + } + log.info("Renamed identity '{}' to '{}' ({})", currentAlias, newAlias, identity.getPublicKey().toBech32String()); + } + + /** + * Forget an identity, wiping its key. + * + *

The public key is logged as it goes, so the audit trail outlives the key it describes: + * afterwards there is nothing left to say which account was destroyed. + * + * @param alias the identity to remove + * @throws IdentityUnknownException when the vault holds no such alias + */ + public synchronized void remove(@NonNull String alias) { + Identity identity = require(alias); + log.info("Removing identity '{}' ({})", alias, identity.getPublicKey().toBech32String()); + byte[] keyMaterial = keyMaterialByAlias.remove(alias); + if (keyMaterial != null) { + Arrays.fill(keyMaterial, (byte) 0); + } + identitiesByAlias.remove(alias); + if (alias.equals(defaultAlias)) { + defaultAlias = identitiesByAlias.size() == 1 ? identitiesByAlias.keySet().iterator().next() : null; + } + } + + /** + * Choose which identity signs when a caller names none. + * + * @param alias the identity to make default + * @throws IdentityUnknownException when the vault holds no such alias + */ + public synchronized void setDefault(@NonNull String alias) { + require(alias); + defaultAlias = alias; + log.info("Default identity is now '{}'", alias); + } + + /** + * Build something that needs to sign, without handing over the key. + * + *

NIP-17 sealing and unwrapping cannot be expressed as "sign this": they derive shared + * secrets, so the SDK's service takes an {@link Identity} rather than a signature. Rather than + * release the key, the vault constructs the collaborator itself and returns only what that + * collaborator exposes, which for {@code DirectMessageService} is composing and reading + * messages and never the identity behind them. + * + *

The factory runs inside the vault and its result must not retain the identity beyond what + * it needs, which is why this takes a factory rather than lending the identity out. + * + * @param alias the identity to build with + * @param collaborator makes the object that needs to sign + * @param what is being built + * @return the built object + * @throws IdentityUnknownException when the vault holds no such alias + */ + public synchronized T using( + @NonNull String alias, @NonNull java.util.function.Function collaborator) { + return collaborator.apply(require(alias)); + } + + /** + * Hand back a copy of an identity's key material, for backing it up. + * + *

The one exception to keys never leaving the vault, and it is narrow on purpose: a backup + * is the only thing that makes removal survivable, and it cannot be written without the key. + * The caller gets a copy it must wipe, and the only caller is the backup writer, which puts + * the bytes straight into an encrypted file and never into a tool result. + * + * @param alias the identity to export + * @return a copy of the private key material + * @throws IdentityUnknownException when the vault holds no such alias + */ + public synchronized byte[] exportKeyMaterial(@NonNull String alias) { + require(alias); + byte[] keyMaterial = keyMaterialByAlias.get(alias); + if (keyMaterial == null) { + throw new KeystoreException("The key material for '" + alias + "' is no longer available"); + } + return keyMaterial.clone(); + } + + /** + * Whether this vault holds any identity at all. + * + * @return true when no key was loaded + */ + public boolean isEmpty() { + return identitiesByAlias.isEmpty(); + } + + private Identity require(String alias) { + Identity identity = identitiesByAlias.get(alias); + if (identity == null) { + throw new IdentityUnknownException(alias, identitiesByAlias.keySet()); + } + return identity; + } + + private void unlock(String alias, byte[] keyMaterial) { + identitiesByAlias.put(alias, Identity.create(new PrivateKey(keyMaterial))); + keyMaterialByAlias.put(alias, keyMaterial); + } + + /** + * Choose the default, preferring an explicit one and falling back to a lone identity. + * + *

A single identity is unambiguous, so requiring a caller to name it would be pedantry. + * Several identities with no explicit default stay ambiguous on purpose: guessing which + * account to post from is a public, irreversible mistake, so signing fails instead. + */ + private String resolveDefault(String requested) { + if (requested != null && identitiesByAlias.containsKey(requested)) { + return requested; + } + if (requested != null) { + throw new IdentityUnknownException(requested, identitiesByAlias.keySet()); + } + return identitiesByAlias.size() == 1 ? identitiesByAlias.keySet().iterator().next() : null; + } + + private void warnIfUnprotected(KeySource keySource) { + if (!keySource.protectsKeysAtRest()) { + log.warn( + "Keystore '{}' does not protect keys at rest and is unsuitable outside development", + keySource.type()); + } + } + + /** Announces what was unlocked, by public key only: a startup banner is still a log. */ + private void announce(KeySource keySource) { + if (identitiesByAlias.isEmpty()) { + log.info("Keystore '{}' holds no identities", keySource.type()); + return; + } + identitiesByAlias.forEach( + (alias, identity) -> + log.info("Unlocked identity '{}' ({})", alias, identity.getPublicKey().toBech32String())); + } + + @Override + public void close() { + keyMaterialByAlias.values().forEach(key -> Arrays.fill(key, (byte) 0)); + keyMaterialByAlias.clear(); + identitiesByAlias.clear(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeyImportSource.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeyImportSource.java new file mode 100644 index 00000000..617b3d33 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeyImportSource.java @@ -0,0 +1,176 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.crypto.bech32.Bech32; +import nostr.mcp.tool.ToolFailure; + +import java.io.Console; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Locale; + +/** + * Where the server should read a key from, when told to import one. + * + *

This type exists to make one thing impossible: a private key travelling through the model. + * An import tool taking an {@code nsec} argument would put the key in the conversation, the + * host's logs, and very probably a third-party inference API, which is the worst thing this + * module could do. So the agent names a location and the server reads it directly; the + * model orchestrates an import it never observes. + * + *

The same principle as keys never leaving the vault, applied to the way in. + */ +@Slf4j +public final class KeyImportSource { + + private static final String FILE_PREFIX = "file:"; + private static final String ENVIRONMENT_PREFIX = "env:"; + private static final String PROMPT = "prompt"; + private static final String NSEC_PREFIX = "nsec"; + + private KeyImportSource() {} + + /** + * Read the key the source names. + * + * @param source {@code file:}, {@code env:}, or {@code prompt} + * @return the private key material, which the caller owns and wipes + * @throws nostr.mcp.tool.ToolException when the source is unreadable, unrecognised, or holds + * something that is not a key + */ + public static byte[] read(@NonNull String source) { + String trimmed = source.trim(); + refuseKeyMaterialAsSource(trimmed); + if (trimmed.startsWith(FILE_PREFIX)) { + return fromFile(trimmed.substring(FILE_PREFIX.length())); + } + if (trimmed.startsWith(ENVIRONMENT_PREFIX)) { + return fromEnvironment(trimmed.substring(ENVIRONMENT_PREFIX.length())); + } + if (PROMPT.equalsIgnoreCase(trimmed)) { + return fromTerminal(); + } + throw ToolFailure.INVALID_ARGUMENT.raise( + "'source' names where the server should read the key from, not the key itself. Use" + + " file:/path/to/key, env:VARIABLE_NAME, or prompt."); + } + + /** + * Refuses a source that is itself a key. + * + *

An agent handed this tool will sometimes try the obvious thing and paste the key. By then + * the secret is already in the conversation, so the message says to treat it as compromised + * rather than merely correcting the argument. + */ + private static void refuseKeyMaterialAsSource(String source) { + boolean looksLikeAKey = + source.toLowerCase(Locale.ROOT).startsWith(NSEC_PREFIX) + || (source.length() == 64 && source.chars().allMatch(KeyImportSource::isHexDigit)); + if (looksLikeAKey) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "That looks like a private key. Never send key material to a tool: it would be written" + + " to the conversation log and may reach a third-party API. Treat this key as" + + " compromised and replace it. To import a key, give a location instead, such as" + + " file:/path/to/key."); + } + } + + private static boolean isHexDigit(int character) { + return (character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f') + || (character >= 'A' && character <= 'F'); + } + + private static byte[] fromFile(String path) { + Path keyFile = Path.of(path); + if (!Files.isReadable(keyFile)) { + throw ToolFailure.INVALID_ARGUMENT.raise("The server cannot read a key file at " + path); + } + try { + return decode(Files.readString(keyFile).trim()); + } catch (IOException e) { + throw ToolFailure.INVALID_ARGUMENT.raise("Could not read the key file at " + path); + } + } + + private static byte[] fromEnvironment(String variableName) { + String value = System.getenv(variableName); + if (value == null || value.isBlank()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "The server has no environment variable called " + variableName); + } + return decode(value.trim()); + } + + /** + * Reads from the server's own terminal, where the agent cannot see. + * + *

Only possible when a console is attached. A server an MCP host launched has its standard + * streams wired to the protocol, so there is nowhere private to type, and saying so is better + * than reading the key off a stream the agent is also holding. + */ + private static byte[] fromTerminal() { + Console console = System.console(); + if (console == null) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "This server has no terminal to prompt at, because its input and output belong to the" + + " MCP host. Import from a file instead: file:/path/to/key."); + } + char[] typed = console.readPassword("Paste the private key to import (it will not be shown): "); + try { + return decode(new String(typed).trim()); + } finally { + Arrays.fill(typed, '\0'); + } + } + + private static byte[] decode(String key) { + if (key.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise("The source held no key"); + } + try { + return key.startsWith(NSEC_PREFIX) + ? HexFormat.of().parseHex(Bech32.fromBech32(key)) + : HexFormat.of().parseHex(key); + } catch (Exception e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "The source did not hold a private key in hex or nsec form"); + } + } + + /** + * Overwrite a key file so the imported key does not sit on disk in plaintext. + * + * @param source the source that was imported from + * @return true when a file was shredded + */ + public static boolean shred(@NonNull String source) { + if (!source.trim().startsWith(FILE_PREFIX)) { + return false; + } + Path keyFile = Path.of(source.trim().substring(FILE_PREFIX.length())); + try { + long length = Files.size(keyFile); + Files.write(keyFile, new byte[(int) length]); + Files.delete(keyFile); + return true; + } catch (IOException e) { + log.warn("Could not shred the key file at {}: {}", keyFile, e.getMessage()); + return false; + } + } + + /** + * The character set a key file is expected in, for callers writing one. + * + * @return UTF-8 + */ + public static java.nio.charset.Charset charset() { + return StandardCharsets.UTF_8; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySource.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySource.java new file mode 100644 index 00000000..e36f0836 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySource.java @@ -0,0 +1,53 @@ +package nostr.mcp.identity; + +import java.util.Map; + +/** + * Where signing keys come from. + * + *

Holding keys locally makes where the central security decision of this module, and + * the answer differs by deployment: a desktop has a keychain, a container has a mounted file, + * a test has neither. This interface is the seam between those, so adding a remote signer later + * means adding an implementation rather than reworking the vault. + * + *

An implementation returns raw key material, which the {@link IdentityVault} takes ownership + * of and wipes. Returning {@code byte[]} rather than a string is deliberate: a string cannot be + * cleared and may be interned, so a leaked heap dump keeps the key indefinitely. + */ +public interface KeySource { + + /** + * Read the keys this source holds that the binding permits. + * + *

Called once at startup. The caller wipes the returned arrays after use, so an + * implementation must not retain them. + * + *

The binding is applied before decryption, not after. A bound process must never + * hold another identity's key even briefly, so an implementation filters by alias while the + * other entries are still encrypted rather than reading everything and discarding the rest. + * + * @param binding which identities this process may unlock + * @return private key material by alias, empty when the source holds nothing permitted + * @throws KeystoreException if the source exists but could not be read + */ + Map loadKeys(IdentityBinding binding); + + /** + * How this source identifies itself in logs and errors. + * + * @return the configuration value that selects it + */ + String type(); + + /** + * Whether this source is safe outside development. + * + *

A source that answers {@code false} is announced at startup, on the principle that a + * weaker choice should be noisy rather than silent. + * + * @return true when the source protects keys at rest + */ + default boolean protectsKeysAtRest() { + return true; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySources.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySources.java new file mode 100644 index 00000000..0983bd9c --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeySources.java @@ -0,0 +1,64 @@ +package nostr.mcp.identity; + +import lombok.NonNull; + +import java.nio.file.Path; +import java.util.List; + +/** + * Chooses a key source from configuration. + * + *

One place decides which backend a {@code keystore.type} means, so the vault never branches + * on it and a new backend is a new case here rather than a change spread across startup. + */ +public final class KeySources { + + private static final String PASSPHRASE_VARIABLE = "NOSTR_MCP_KEYSTORE_PASSPHRASE"; + + private KeySources() {} + + /** + * Build the configured source. + * + * @param type the {@code keystore.type} value + * @param keystorePath where an encrypted-file keystore lives + * @param aliases the identities to look for, for backends that cannot enumerate themselves + * @return the source to load keys from + * @throws KeystoreException when the type is unknown or its prerequisites are missing + */ + public static KeySource forType( + @NonNull String type, @NonNull String keystorePath, @NonNull List aliases) { + return switch (type) { + case OsKeychainKeySource.TYPE -> new OsKeychainKeySource(aliases); + case EnvironmentKeySource.TYPE -> new EnvironmentKeySource(); + case EncryptedFileKeySource.TYPE -> + new EncryptedFileKeySource(Path.of(keystorePath), requirePassphrase()); + default -> + throw new KeystoreException( + "Unknown keystore.type '" + + type + + "'; expected one of " + + List.of( + OsKeychainKeySource.TYPE, + EncryptedFileKeySource.TYPE, + EnvironmentKeySource.TYPE)); + }; + } + + /** + * Reads the passphrase from the environment. + * + *

An encrypted keystore with no passphrase is a file with a lock and no key, so a missing + * one is refused rather than defaulted. The message names the variable, because a server an + * MCP host launches has nowhere to prompt. + */ + private static char[] requirePassphrase() { + String passphrase = System.getenv(PASSPHRASE_VARIABLE); + if (passphrase == null || passphrase.isBlank()) { + throw new KeystoreException( + "The " + EncryptedFileKeySource.TYPE + " keystore needs a passphrase; set " + + PASSPHRASE_VARIABLE); + } + return passphrase.toCharArray(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeystoreException.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeystoreException.java new file mode 100644 index 00000000..497167af --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/KeystoreException.java @@ -0,0 +1,28 @@ +package nostr.mcp.identity; + +/** + * Thrown when a keystore exists but cannot be used. + * + *

Distinct from an empty keystore, which is an ordinary state a first-run server reports and + * explains. This means the keys are there and something is wrong: a bad passphrase, unsafe file + * permissions, an unreadable platform keychain. The server refuses to start rather than + * continuing without the identities it was configured with, since silently signing as nobody is + * worse than not starting. + */ +public class KeystoreException extends RuntimeException { + + /** + * @param message what could not be read, and why + */ + public KeystoreException(String message) { + super(message); + } + + /** + * @param message what could not be read, and why + * @param cause the underlying failure + */ + public KeystoreException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/OsKeychainKeySource.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/OsKeychainKeySource.java new file mode 100644 index 00000000..c345034b --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/OsKeychainKeySource.java @@ -0,0 +1,306 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Keys held by the operating system's own secret store. + * + *

The default, because it needs no passphrase and so does not block an unattended start, and + * because on a desktop the platform protects a secret better than a file this process can read. + * + *

It reaches the store through the platform's command-line tool rather than a native binding: + * {@code security} on macOS, {@code secret-tool} on Linux. That keeps the module free of a + * native dependency at the cost of shelling out, which is acceptable for something done once at + * startup. + * + *

When no such tool exists, this source reports itself unavailable rather than throwing. + * A container has no keychain, and a server that dies there because of a default would be a + * default that punishes the deployment it was not chosen for. + */ +@Slf4j +public final class OsKeychainKeySource implements KeySource, IdentityStore { + + /** The value that selects this backend. */ + public static final String TYPE = "os-keychain"; + + private static final String SERVICE_NAME = "nostr-java-mcp"; + private static final long LOOKUP_TIMEOUT_SECONDS = 10; + + private final KeychainCommand command; + private final List aliases; + private final AliasIndex aliasIndex; + + /** + * Reads the named identities from whichever platform tool is present. + * + * @param aliases the identities to look for + */ + public OsKeychainKeySource(@NonNull List aliases) { + this(KeychainCommand.forThisPlatform(), aliases, defaultIndexPath()); + } + + /** + * @param command the platform tool to invoke + * @param aliases the identities to look for + */ + public OsKeychainKeySource(@NonNull KeychainCommand command, @NonNull List aliases) { + this(command, aliases, defaultIndexPath()); + } + + private static java.nio.file.Path defaultIndexPath() { + return java.nio.file.Path.of(System.getProperty("user.home"), ".nostr-java", "aliases"); + } + + /** + * @param command the platform tool to invoke, so a test need not touch a real keychain + * @param aliases the identities to look for + */ + public OsKeychainKeySource( + @NonNull KeychainCommand command, + @NonNull List aliases, + @NonNull java.nio.file.Path indexPath) { + this.command = command; + this.aliases = List.copyOf(aliases); + this.aliasIndex = new AliasIndex(indexPath); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public Map loadKeys(IdentityBinding binding) { + if (!command.isAvailable()) { + log.warn( + "No platform keychain is available; configure keystore.type={} instead", + EncryptedFileKeySource.TYPE); + return Map.of(); + } + Map keys = new LinkedHashMap<>(); + java.util.Set known = new java.util.LinkedHashSet<>(aliases); + known.addAll(aliasIndex.read()); + for (String alias : binding.permitted(known)) { + command.readSecret(SERVICE_NAME, alias).ifPresent(hex -> keys.put(alias, decode(alias, hex))); + } + return keys; + } + + @Override + public void store(@NonNull String alias, @NonNull byte[] keyMaterial) { + requireKeychain(); + if (!command.writeSecret(SERVICE_NAME, alias, HexFormat.of().formatHex(keyMaterial))) { + throw new KeystoreException("The platform keychain refused to store the key for '" + alias + "'"); + } + aliasIndex.add(alias); + } + + @Override + public List aliases() { + return aliasIndex.read(); + } + + @Override + public boolean remove(@NonNull String alias) { + requireKeychain(); + boolean removed = command.deleteSecret(SERVICE_NAME, alias); + aliasIndex.remove(alias); + return removed; + } + + /** + * Administration needs a keychain, where reading may tolerate its absence. + * + *

A missing keychain makes a server start empty, which is recoverable, but makes {@code + * keygen} silently do nothing, which is not. + */ + private void requireKeychain() { + if (!command.isAvailable()) { + throw new KeystoreException( + "No platform keychain is available on this host; use keystore.type=" + + EncryptedFileKeySource.TYPE + + " instead"); + } + } + + private byte[] decode(String alias, String hex) { + try { + return HexFormat.of().parseHex(hex.trim()); + } catch (IllegalArgumentException e) { + throw new KeystoreException("Keychain entry '" + alias + "' is not a hex private key", e); + } + } + + /** + * The platform tool this source talks to. + * + *

An interface rather than a branch on the OS name, so a test can exercise the source + * without a keychain and a new platform is a new implementation. + */ + public interface KeychainCommand { + + /** + * Whether this platform's secret store can be reached. + * + * @return true when the tool exists and responds + */ + boolean isAvailable(); + + /** + * Read one secret. + * + * @param service the service the key is filed under + * @param alias the key's alias + * @return the stored value, or empty when there is none + */ + Optional readSecret(String service, String alias); + + /** + * Store one secret, replacing any existing value. + * + * @param service the service to file it under + * @param alias the key's alias + * @param secret the value to store + * @return true when the platform tool accepted it + */ + boolean writeSecret(String service, String alias, String secret); + + /** + * Forget one secret. + * + * @param service the service it is filed under + * @param alias the key's alias + * @return true when an entry was removed + */ + boolean deleteSecret(String service, String alias); + + /** + * The tool for the running platform. + * + * @return a command that reports itself unavailable when no tool is present + */ + static KeychainCommand forThisPlatform() { + return new ProcessKeychainCommand(); + } + } + + /** Invokes the platform tool as a subprocess. */ + private static final class ProcessKeychainCommand implements KeychainCommand { + + private static final String MACOS_TOOL = "security"; + private static final String LINUX_TOOL = "secret-tool"; + + @Override + public boolean isAvailable() { + return toolName() != null; + } + + @Override + public Optional readSecret(String service, String alias) { + String tool = toolName(); + if (tool == null) { + return Optional.empty(); + } + List arguments = + MACOS_TOOL.equals(tool) + ? List.of(tool, "find-generic-password", "-s", service, "-a", alias, "-w") + : List.of(tool, "lookup", "service", service, "account", alias); + return runQuietly(arguments); + } + + @Override + public boolean writeSecret(String service, String alias, String secret) { + String tool = toolName(); + if (tool == null) { + return false; + } + if (MACOS_TOOL.equals(tool)) { + return writeToStdin( + List.of(tool, "add-generic-password", "-U", "-s", service, "-a", alias, "-w"), secret); + } + return writeToStdin( + List.of(tool, "store", "--label", service + ":" + alias, "service", service, "account", alias), + secret); + } + + @Override + public boolean deleteSecret(String service, String alias) { + String tool = toolName(); + if (tool == null) { + return false; + } + List arguments = + MACOS_TOOL.equals(tool) + ? List.of(tool, "delete-generic-password", "-s", service, "-a", alias) + : List.of(tool, "clear", "service", service, "account", alias); + return runQuietly(arguments).isPresent(); + } + + /** + * Passes a secret on stdin rather than in the argument list. + * + *

Arguments are visible in the process table to every user on the host, so a secret given + * that way is a secret published. Both platform tools read the value from stdin when it is + * omitted from the arguments, which is why neither branch passes it inline. + */ + private boolean writeToStdin(List arguments, String secret) { + try { + Process process = new ProcessBuilder(arguments).start(); + try (var stdin = process.getOutputStream()) { + stdin.write(secret.getBytes(StandardCharsets.UTF_8)); + } + if (!process.waitFor(LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException e) { + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + private String toolName() { + if (isOnPath(MACOS_TOOL) && System.getProperty("os.name", "").toLowerCase().contains("mac")) { + return MACOS_TOOL; + } + return isOnPath(LINUX_TOOL) ? LINUX_TOOL : null; + } + + private boolean isOnPath(String tool) { + return runQuietly(List.of("which", tool)).isPresent(); + } + + private Optional runQuietly(List arguments) { + try { + Process process = + new ProcessBuilder(arguments).redirectErrorStream(false).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + return Optional.empty(); + } + return process.exitValue() == 0 && !output.isBlank() + ? Optional.of(output) + : Optional.empty(); + } catch (IOException e) { + return Optional.empty(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/query/EventQuery.java b/nostr-java-mcp/src/main/java/nostr/mcp/query/EventQuery.java new file mode 100644 index 00000000..264b3a5d --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/query/EventQuery.java @@ -0,0 +1,151 @@ +package nostr.mcp.query; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.client.relay.RelayPool; +import nostr.client.relay.RelaySubscription; +import nostr.client.relay.SubscriptionListener; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.mcp.tool.ToolFailure; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Runs a one-shot query and returns what the relays had. + * + *

The SDK offers subscriptions, not queries: {@code subscribe} returns immediately and events + * arrive later on transport threads. A tool call has to answer once, so this collects until the + * end-of-stored-events signal, then unsubscribes. That translation belongs in one place, because + * every read tool needs it and getting the termination conditions wrong is how a tool call hangs. + * + *

Bounded twice over, and both bounds matter. A relay serves one request at a time, so an + * unbounded query stalls every other tool call behind it; and an agent's context is finite, so + * ten thousand events would be useless even if they arrived. Reaching either bound is reported + * rather than hidden, since an agent that does not know its answer was truncated will draw + * conclusions from a partial view. + */ +@Slf4j +public final class EventQuery { + + private final RelayPool relayPool; + + /** + * @param relayPool the relays to ask + */ + public EventQuery(@NonNull RelayPool relayPool) { + this.relayPool = relayPool; + } + + /** + * Collect the events matching a filter. + * + * @param filter what to ask for + * @param maxEvents the most events to return + * @param timeout how long to wait for the relays to finish replaying + * @return the events, newest first, and whether the answer was cut short + * @throws nostr.mcp.tool.ToolException when no relay could be reached + */ + public QueryResult run(@NonNull EventFilter filter, int maxEvents, @NonNull java.time.Duration timeout) { + Collector collector = new Collector(maxEvents); + try (RelaySubscription subscription = subscribe(filter, collector)) { + boolean backlogDrained = collector.awaitCompletion(timeout); + return collector.result(backlogDrained, subscription.getSubscribedRelays().size()); + } + } + + private RelaySubscription subscribe(EventFilter filter, SubscriptionListener listener) { + try { + RelaySubscription subscription = relayPool.subscribe(List.of(filter), listener); + if (subscription.getSubscribedRelays().isEmpty()) { + subscription.close(); + throw ToolFailure.RELAY_UNREACHABLE.raise( + "No relay accepted the query; none of the configured relays is currently connected"); + } + return subscription; + } catch (RuntimeException e) { + if (e instanceof nostr.mcp.tool.ToolException) { + throw e; + } + throw ToolFailure.RELAY_UNREACHABLE.raise("Could not query any relay: " + e.getMessage()); + } + } + + /** + * Gathers events off the transport threads until the relays finish or a bound is reached. + * + *

Events arrive on whichever thread the transport dispatches them from, so the list is + * guarded and the waiting is done with a latch rather than by polling. + */ + private static final class Collector implements SubscriptionListener { + + private final List events = new ArrayList<>(); + private final CountDownLatch finished = new CountDownLatch(1); + private final int maxEvents; + private boolean truncated; + + private Collector(int maxEvents) { + this.maxEvents = maxEvents; + } + + /** + * Keeps one event beyond the limit as evidence that more exist. + * + *

The extra event is never returned. It is the only way to distinguish "the relay held + * exactly this many" from "there were more and I stopped", because a relay that honours the + * filter's own limit stops sending at exactly the boundary and its end-of-stored-events + * signal looks identical in both cases. + */ + @Override + public void onEvent(GenericEvent event) { + synchronized (events) { + if (events.size() >= maxEvents) { + truncated = true; + finished.countDown(); + return; + } + events.add(event); + } + } + + @Override + public void onEndOfStoredEvents() { + finished.countDown(); + } + + @Override + public void onRelayFailure(String relayUri, Throwable failure) { + log.debug("Relay {} failed during a query: {}", relayUri, failure.toString()); + } + + private boolean awaitCompletion(java.time.Duration timeout) { + try { + return finished.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * Reports what was collected, newest first. + * + *

Sorted here rather than trusted from the relays: NIP-01 asks relays to send stored + * events newest first, but a query spanning several relays interleaves their streams, so the + * combined order is only what this makes it. + */ + private QueryResult result(boolean backlogDrained, int relayCount) { + synchronized (events) { + List ordered = + events.stream() + .sorted(Comparator.comparing(GenericEvent::getCreatedAt, Comparator.reverseOrder())) + .toList(); + return new QueryResult(ordered, truncated, !backlogDrained, relayCount); + } + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryLimits.java b/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryLimits.java new file mode 100644 index 00000000..98f35cbe --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryLimits.java @@ -0,0 +1,29 @@ +package nostr.mcp.query; + +import java.time.Duration; + +/** + * The bounds every read stays inside. + * + *

A relay serves one request at a time, so a query with no ceiling stalls every other tool + * call behind it, and an agent's context is finite, so an unbounded result is unusable even when + * it arrives. Both limits are configuration rather than constants because the right values + * depend on the relays a deployment talks to. + * + * @param maxEventsPerQuery the most events any one query may return + * @param queryTimeout how long to wait for relays to finish replaying + */ +public record QueryLimits(int maxEventsPerQuery, Duration queryTimeout) { + + private static final int DEFAULT_MAX_EVENTS = 500; + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); + + /** + * The defaults from the specification. + * + * @return limits suitable for an ordinary desktop deployment + */ + public static QueryLimits defaults() { + return new QueryLimits(DEFAULT_MAX_EVENTS, DEFAULT_TIMEOUT); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryResult.java b/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryResult.java new file mode 100644 index 00000000..596781cb --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/query/QueryResult.java @@ -0,0 +1,30 @@ +package nostr.mcp.query; + +import nostr.event.impl.GenericEvent; + +import java.util.List; + +/** + * What a one-shot query found, and what it might have missed. + * + *

The two flags are the point. An agent given a truncated or timed-out answer as though it + * were complete will state conclusions the data does not support, so "there are no mentions" and + * "I stopped looking" are kept distinguishable all the way to the model. + * + * @param events the matching events, newest first + * @param truncated whether the event limit cut the answer short + * @param timedOut whether the relays had not finished replaying when the deadline passed + * @param relayCount how many relays answered + */ +public record QueryResult( + List events, boolean truncated, boolean timedOut, int relayCount) { + + /** + * Whether the answer is everything the relays hold. + * + * @return true when neither bound was reached + */ + public boolean isComplete() { + return !truncated && !timedOut; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/relay/RelayDirectory.java b/nostr-java-mcp/src/main/java/nostr/mcp/relay/RelayDirectory.java new file mode 100644 index 00000000..e1d942f4 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/relay/RelayDirectory.java @@ -0,0 +1,73 @@ +package nostr.mcp.relay; + +import lombok.NonNull; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Resolves the relay names an agent uses into the URIs the SDK connects to. + * + *

An agent should be able to say "publish to my write relays" without knowing a websocket + * URI, and a deployment should be able to change where that points without the agent noticing. + * This holds that mapping and nothing else. + * + *

It deliberately owns no connections. Those belong to the SDK's {@code RelayPool}, and a + * second thing in the codebase called a relay pool would be a trap for anyone reading either. + */ +public final class RelayDirectory { + + /** The logical name for relays events are published to. */ + public static final String WRITE = "write"; + + /** The logical name for relays events are read from. */ + public static final String READ = "read"; + + private final Map> relaysByName; + + /** + * @param relaysByName logical names mapped to the relay URIs they stand for + */ + public RelayDirectory(@NonNull Map> relaysByName) { + this.relaysByName = Map.copyOf(relaysByName); + } + + /** + * Resolve names or URIs into relay URIs. + * + *

A caller may pass either, since an agent naturally writes {@code "write"} while a + * configuration file names a specific relay. Anything not a known name is taken to be a URI, + * which keeps a one-off relay usable without registering it first. + * + * @param namesOrUris logical names, relay URIs, or a mixture + * @return the resolved URIs, deduplicated, in the order given + */ + public List resolve(@NonNull List namesOrUris) { + Set resolved = new LinkedHashSet<>(); + namesOrUris.forEach( + nameOrUri -> resolved.addAll(relaysByName.getOrDefault(nameOrUri, List.of(nameOrUri)))); + return List.copyOf(resolved); + } + + /** + * Every relay this directory knows about, whatever name it was registered under. + * + * @return the distinct relay URIs + */ + public List allRelayUris() { + Set all = new LinkedHashSet<>(); + relaysByName.values().forEach(all::addAll); + return List.copyOf(all); + } + + /** + * The logical names an agent can use. + * + * @return the registered names + */ + public Set names() { + return relaysByName.keySet(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/social/McpDirectMessageService.java b/nostr-java-mcp/src/main/java/nostr/mcp/social/McpDirectMessageService.java new file mode 100644 index 00000000..d119523f --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/social/McpDirectMessageService.java @@ -0,0 +1,125 @@ +package nostr.mcp.social; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.api.DirectMessagePublisher; +import nostr.api.RecipientDeliveryOutcome; +import nostr.api.RelayListLookup; +import nostr.base.PublicKey; +import nostr.client.relay.RelayPool; +import nostr.encryption.Nip17DirectMessageService; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.tool.ToolFailure; + +import java.util.List; +import java.util.Set; + +/** + * Sends and reads private messages without the tool layer ever holding a key. + * + *

NIP-17 sealing derives shared secrets, so the SDK's service needs an {@link + * nostr.id.Identity} rather than a signature. Building it inside the vault keeps the boundary + * intact: this class receives something that can compose and read messages, and never the + * identity that does so. + * + *

Decryption is opt-in per identity. Reading someone's private correspondence puts it into + * the model's context and therefore into the host's logs and probably a third-party API, which + * is a decision for the person whose messages they are rather than a default. + */ +@Slf4j +public final class McpDirectMessageService { + + private final IdentityVault identityVault; + private final RelayPool relayPool; + private final Set aliasesPermittedToDecrypt; + + /** + * @param identityVault holds the keys and builds the signing collaborator + * @param relayPool the connections used to deliver + * @param aliasesPermittedToDecrypt identities whose owner has allowed reading their messages + */ + public McpDirectMessageService( + @NonNull IdentityVault identityVault, + @NonNull RelayPool relayPool, + @NonNull Set aliasesPermittedToDecrypt) { + this.identityVault = identityVault; + this.relayPool = relayPool; + this.aliasesPermittedToDecrypt = Set.copyOf(aliasesPermittedToDecrypt); + } + + /** + * Send a message to each recipient's own relays. + * + * @param alias the identity to send as + * @param recipients who to send to + * @param content the message text + * @return one outcome per participant, the sender included + */ + public List send( + @NonNull String alias, @NonNull List recipients, @NonNull String content) { + ChatMessage message = + ChatMessage.builder() + .from(identityVault.publicKeyOf(alias)) + .to(recipients) + .content(content) + .build(); + return publisherFor(alias).send(message); + } + + /** + * Unwrap a gift wrap addressed to an identity. + * + * @param alias the identity the wrap is addressed to + * @param giftWrap the received wrap + * @return the message inside + * @throws nostr.mcp.tool.ToolException when this identity may not decrypt + */ + public ChatMessage read(@NonNull String alias, @NonNull GenericEvent giftWrap) { + refuseIfDecryptionNotPermitted(alias); + return publisherFor(alias).read(giftWrap); + } + + /** + * Whether this identity's owner has allowed the model to read their messages. + * + * @param alias the identity to check + * @return true when decryption is permitted + */ + public boolean mayDecrypt(@NonNull String alias) { + return aliasesPermittedToDecrypt.contains(alias); + } + + /** + * The public key an identity receives messages at. + * + * @param alias the identity + * @return its public key + */ + public PublicKey publicKeyOf(@NonNull String alias) { + return identityVault.publicKeyOf(alias); + } + + private void refuseIfDecryptionNotPermitted(String alias) { + if (!mayDecrypt(alias)) { + throw ToolFailure.WRITE_FORBIDDEN.raise( + "Reading '" + + alias + + "' private messages is not enabled. Decrypting them would put private" + + " correspondence into this conversation, so it must be allowed explicitly with" + + " nostr.mcp.dm.decrypt-for=" + alias + "."); + } + } + + /** + * Builds the publisher inside the vault, so the identity never crosses this boundary. + */ + private DirectMessagePublisher publisherFor(String alias) { + return identityVault.using( + alias, + identity -> + new DirectMessagePublisher( + new Nip17DirectMessageService(identity), new RelayListLookup(relayPool), relayPool)); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/subscription/EventBuffer.java b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/EventBuffer.java new file mode 100644 index 00000000..9d1d5285 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/EventBuffer.java @@ -0,0 +1,107 @@ +package nostr.mcp.subscription; + +import lombok.NonNull; +import nostr.event.impl.GenericEvent; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Holds the events that arrived since the agent last looked. + * + *

MCP has no way to push into a tool result, so events arriving between polls must wait + * somewhere. This is that place, and it is bounded: an agent that opens a subscription and + * forgets it must not be able to exhaust the server's memory, and a buffer large enough to be + * unbounded would exceed a model's context anyway. + * + *

Overflow drops the oldest and counts what it dropped. The count matters more than the + * events: an agent that silently receives a gap will summarise a partial feed as though it were + * complete, whereas one told it missed thirty events can say so or narrow its filter. + */ +public final class EventBuffer { + + private final Deque events = new ArrayDeque<>(); + private final Set seenEventIds = new LinkedHashSet<>(); + private final int capacity; + private long droppedCount; + + /** + * @param capacity the most events to hold before dropping the oldest + */ + public EventBuffer(int capacity) { + this.capacity = capacity; + } + + /** + * Take an event, dropping the oldest if full. + * + *

A repeat is ignored rather than stored twice. The SDK already delivers each event once + * however many relays carry it, but it does so over a bounded window, so a relay replaying an + * old event long afterwards can arrive as a duplicate. An agent seeing the same note twice + * would reasonably conclude it was posted twice. + * + * @param event the event to hold + */ + public synchronized void add(@NonNull GenericEvent event) { + if (!seenEventIds.add(event.getId())) { + return; + } + if (events.size() >= capacity) { + GenericEvent dropped = events.pollFirst(); + if (dropped != null) { + seenEventIds.remove(dropped.getId()); + } + droppedCount++; + } + events.addLast(event); + } + + /** + * Take everything held, leaving the buffer empty. + * + *

Draining is what keeps repeated polls from refilling an agent's context with events it + * has already read. This is a command that also answers, which is normally worth avoiding, but + * an at-most-once read is the property that makes polling usable at all. + * + * @return the events in arrival order + */ + public synchronized List drain() { + List drained = List.copyOf(events); + events.clear(); + seenEventIds.clear(); + return drained; + } + + /** + * How many events are waiting. + * + * @return the current depth + */ + public synchronized int depth() { + return events.size(); + } + + /** + * How many events were dropped for want of room, over the buffer's whole life. + * + *

Monotonic on purpose: it survives draining, so an agent polling repeatedly can tell that + * a gap happened at some point rather than only within the last window. + * + * @return the total dropped + */ + public synchronized long droppedCount() { + return droppedCount; + } + + /** + * The most events this buffer holds. + * + * @return the capacity + */ + public int capacity() { + return capacity; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/subscription/LiveSubscription.java b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/LiveSubscription.java new file mode 100644 index 00000000..0b6ce6c6 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/LiveSubscription.java @@ -0,0 +1,184 @@ +package nostr.mcp.subscription; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.client.relay.RelaySubscription; +import nostr.client.relay.SubscriptionListener; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; + +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * One open subscription, and everything the agent needs to know about its health. + * + *

A subscription is the only stateful thing this module keeps, because "watch my mentions" + * cannot be answered by a call that returns. It holds a buffer for what arrived, a record of + * which relays have failed, and the time it was last read, which is what lets an abandoned one + * be reaped. + */ +@Slf4j +public final class LiveSubscription implements AutoCloseable, SubscriptionListener { + + private final Map failuresByRelay = new ConcurrentHashMap<>(); + private final AtomicBoolean backlogDrained = new AtomicBoolean(); + private final String id; + private final EventFilter filter; + private final EventBuffer buffer; + private final Clock clock; + private final Runnable onEventArrived; + private volatile RelaySubscription relaySubscription; + private volatile long lastReadAtMillis; + + /** + * @param id the handle an agent uses to name this subscription + * @param filter what it asked for + * @param bufferCapacity how many events to hold between polls + * @param clock the source of time for idleness + * @param onEventArrived called when an event lands, so a host can be notified + */ + public LiveSubscription( + @NonNull String id, + @NonNull EventFilter filter, + int bufferCapacity, + @NonNull Clock clock, + @NonNull Runnable onEventArrived) { + this.id = id; + this.filter = filter; + this.buffer = new EventBuffer(bufferCapacity); + this.clock = clock; + this.onEventArrived = onEventArrived; + this.lastReadAtMillis = clock.millis(); + } + + /** + * Attach the relay-level subscription this one is fed by. + * + * @param relaySubscription the SDK subscription to close when this one closes + */ + public void attach(@NonNull RelaySubscription relaySubscription) { + this.relaySubscription = relaySubscription; + } + + @Override + public void onEvent(GenericEvent event) { + buffer.add(event); + onEventArrived.run(); + } + + @Override + public void onEndOfStoredEvents() { + backlogDrained.set(true); + } + + /** + * Records a relay dropping out, so degraded coverage is visible. + * + *

A subscription across three relays that is quietly down to one still returns events, and + * an agent with no way to see that will read the thinner feed as the whole story. + */ + @Override + public void onRelayFailure(String relayUri, Throwable failure) { + failuresByRelay.put(relayUri, failure.getMessage() == null ? failure.toString() : failure.getMessage()); + log.debug("Subscription {} lost relay {}: {}", id, relayUri, failure.toString()); + } + + /** + * Take the events that have arrived, leaving the buffer empty. + * + * @return what was waiting + */ + public List drain() { + lastReadAtMillis = clock.millis(); + return buffer.drain(); + } + + /** + * The handle an agent names this subscription by. + * + * @return the subscription id + */ + public String id() { + return id; + } + + /** + * What this subscription asked for. + * + * @return the filter + */ + public EventFilter filter() { + return filter; + } + + /** + * Whether every relay has finished replaying its stored events. + * + *

False does not mean empty. The SDK's subscribe returns before any stored event arrives, + * so an agent that reads immediately may legitimately see nothing yet, and this flag is how it + * tells that from a filter that matches nothing. + * + * @return true once the backlog has drained + */ + public boolean backlogDrained() { + return backlogDrained.get(); + } + + /** + * How many events are waiting to be read. + * + * @return the buffer depth + */ + public int depth() { + return buffer.depth(); + } + + /** + * How many events were dropped because the buffer was full. + * + * @return the total dropped + */ + public long droppedCount() { + return buffer.droppedCount(); + } + + /** + * The relays currently feeding this subscription. + * + * @return the relay URIs + */ + public Set subscribedRelays() { + return relaySubscription == null ? Set.of() : relaySubscription.getSubscribedRelays(); + } + + /** + * The relays that have failed, and why. + * + * @return failure messages by relay URI + */ + public Map failures() { + return Map.copyOf(failuresByRelay); + } + + /** + * How long since the agent last read this subscription. + * + * @return the idle time in milliseconds + */ + public long idleMillis() { + return clock.millis() - lastReadAtMillis; + } + + @Override + public void close() { + if (relaySubscription != null) { + relaySubscription.close(); + } + buffer.drain(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionLimits.java b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionLimits.java new file mode 100644 index 00000000..f25e933e --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionLimits.java @@ -0,0 +1,31 @@ +package nostr.mcp.subscription; + +import java.time.Duration; + +/** + * The bounds every subscription lives inside. + * + *

All three exist because an agent's session can end without this server being told, so a + * subscription that is never closed, never read, or opened in a loop must not be able to consume + * the process indefinitely. + * + * @param maxSubscriptions how many may be open at once + * @param bufferCapacity how many events one holds between polls + * @param idleTimeout how long an unread subscription survives + */ +public record SubscriptionLimits(int maxSubscriptions, int bufferCapacity, Duration idleTimeout) { + + private static final int DEFAULT_MAX_SUBSCRIPTIONS = 20; + private static final int DEFAULT_BUFFER_CAPACITY = 500; + private static final Duration DEFAULT_IDLE_TIMEOUT = Duration.ofHours(1); + + /** + * The defaults from the specification. + * + * @return limits suitable for an ordinary desktop deployment + */ + public static SubscriptionLimits defaults() { + return new SubscriptionLimits( + DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_BUFFER_CAPACITY, DEFAULT_IDLE_TIMEOUT); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionRegistry.java b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionRegistry.java new file mode 100644 index 00000000..25cb1fa5 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionRegistry.java @@ -0,0 +1,199 @@ +package nostr.mcp.subscription; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.client.relay.RelayPool; +import nostr.event.filter.EventFilter; +import nostr.mcp.tool.ToolFailure; + +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * Owns every open subscription, and closes the ones nobody is reading. + * + *

Subscriptions are the module's only long-lived resource, and each one holds a relay + * subscription and a buffer. An agent's session ends whenever its user closes a window, without + * telling this server, so without reaping an abandoned conversation would leak relay traffic and + * memory for as long as the process ran. The idle timeout is what makes them safe to hand out. + * + *

The total is capped for the same reason: a model in a loop opening subscriptions is an + * ordinary failure, and a cap turns it into an error the agent can see rather than a server that + * slowly stops responding. + */ +@Slf4j +public final class SubscriptionRegistry implements AutoCloseable { + + private final Map subscriptionsById = new ConcurrentHashMap<>(); + private final AtomicLong nextId = new AtomicLong(1); + private final ScheduledExecutorService reaper = + Executors.newSingleThreadScheduledExecutor( + runnable -> Thread.ofVirtual().name("nostr-mcp-subscription-reaper").unstarted(runnable)); + private final RelayPool relayPool; + private final SubscriptionLimits limits; + private final Clock clock; + private final Consumer onSubscriptionUpdated; + + /** + * @param relayPool the relays to subscribe across + * @param limits how many subscriptions, how deep, and how long idle + * @param clock the source of time for idleness + * @param onSubscriptionUpdated called with the id when events arrive, so a host can be notified + */ + public SubscriptionRegistry( + @NonNull RelayPool relayPool, + @NonNull SubscriptionLimits limits, + @NonNull Clock clock, + @NonNull Consumer onSubscriptionUpdated) { + this.relayPool = relayPool; + this.limits = limits; + this.clock = clock; + this.onSubscriptionUpdated = onSubscriptionUpdated; + scheduleReaping(); + } + + /** + * Open a subscription across every relay. + * + * @param filter what to watch for + * @return the new subscription, whose backlog has not yet drained + * @throws nostr.mcp.tool.ToolException when the cap is reached or no relay accepted it + */ + public LiveSubscription open(@NonNull EventFilter filter) { + refuseIfAtCapacity(); + String id = "sub-" + nextId.getAndIncrement(); + LiveSubscription subscription = + new LiveSubscription( + id, filter, limits.bufferCapacity(), clock, () -> onSubscriptionUpdated.accept(id)); + subscription.attach(subscribeOrFail(filter, subscription)); + subscriptionsById.put(id, subscription); + log.info("Opened subscription {} across {}", id, subscription.subscribedRelays()); + return subscription; + } + + /** + * Find an open subscription. + * + * @param id the handle the agent was given + * @return the subscription + * @throws nostr.mcp.tool.ToolException when no such subscription is open + */ + public LiveSubscription require(@NonNull String id) { + LiveSubscription subscription = subscriptionsById.get(id); + if (subscription == null) { + throw ToolFailure.SUBSCRIPTION_UNKNOWN.raise( + "No subscription called '" + + id + + "'. It may have been closed, or reaped after being idle. Open: " + + subscriptionsById.keySet()); + } + return subscription; + } + + /** + * Every open subscription. + * + * @return the subscriptions, oldest first + */ + public List list() { + return subscriptionsById.values().stream() + .sorted(java.util.Comparator.comparing(LiveSubscription::id)) + .toList(); + } + + /** + * Close a subscription and free its buffer. + * + * @param id the subscription to close + * @return the closed subscription + * @throws nostr.mcp.tool.ToolException when no such subscription is open + */ + public LiveSubscription close(@NonNull String id) { + LiveSubscription subscription = require(id); + subscription.close(); + subscriptionsById.remove(id); + log.info("Closed subscription {}", id); + return subscription; + } + + /** + * Look up a subscription without failing. + * + * @param id the subscription to find + * @return the subscription, or empty when it is not open + */ + public Optional find(@NonNull String id) { + return Optional.ofNullable(subscriptionsById.get(id)); + } + + private void refuseIfAtCapacity() { + if (subscriptionsById.size() >= limits.maxSubscriptions()) { + throw ToolFailure.SUBSCRIPTION_LIMIT_REACHED.raise( + "This server already has " + + limits.maxSubscriptions() + + " open subscriptions, which is the limit. Close one with nostr_unsubscribe" + + " first. Open: " + + subscriptionsById.keySet()); + } + } + + private nostr.client.relay.RelaySubscription subscribeOrFail( + EventFilter filter, LiveSubscription listener) { + nostr.client.relay.RelaySubscription subscription = + relayPool.subscribe(List.of(filter), listener); + if (subscription.getSubscribedRelays().isEmpty()) { + subscription.close(); + throw ToolFailure.RELAY_UNREACHABLE.raise( + "No relay accepted the subscription; none of the configured relays is connected"); + } + return subscription; + } + + /** + * Closes subscriptions nobody has read for a while. + * + *

Idleness is measured from the last read rather than the last event, because a subscription + * that is receiving events nobody collects is exactly the abandoned case: a busy filter would + * otherwise keep a forgotten subscription alive indefinitely. + */ + private void scheduleReaping() { + Duration interval = limits.idleTimeout().dividedBy(2); + reaper.scheduleWithFixedDelay( + this::reapIdleSubscriptions, + interval.toMillis(), + Math.max(interval.toMillis(), 1), + TimeUnit.MILLISECONDS); + } + + private void reapIdleSubscriptions() { + try { + subscriptionsById.values().stream() + .filter(subscription -> subscription.idleMillis() > limits.idleTimeout().toMillis()) + .map(LiveSubscription::id) + .toList() + .forEach( + id -> { + log.info("Reaping subscription {} after {} idle", id, limits.idleTimeout()); + close(id); + }); + } catch (RuntimeException e) { + log.warn("Could not reap idle subscriptions: {}", e.getMessage()); + } + } + + @Override + public void close() { + reaper.shutdownNow(); + subscriptionsById.values().forEach(LiveSubscription::close); + subscriptionsById.clear(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionResources.java b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionResources.java new file mode 100644 index 00000000..21d625c6 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/subscription/SubscriptionResources.java @@ -0,0 +1,93 @@ +package nostr.mcp.subscription; + +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import lombok.NonNull; +import nostr.event.impl.GenericEvent; + +import java.util.List; + +/** + * Exposes subscriptions as MCP resources so a host can watch them. + * + *

A host that supports resource subscriptions is notified when events arrive and can read + * them without the model deciding to poll, which is the difference between "tell me when I am + * mentioned" working and requiring the agent to remember to check. A host that does not support + * them loses nothing, because the polling tool remains. + * + *

Reading a resource does not drain the buffer, unlike the tool. A notification may + * reach several observers, and a read that consumed the events would mean whichever one arrived + * first silently took them from the others. + */ +public final class SubscriptionResources { + + private static final String URI_PREFIX = "nostr://subscription/"; + private static final String MEDIA_TYPE = "application/json"; + + private SubscriptionResources() {} + + /** + * The resource URI naming one subscription. + * + * @param subscriptionId the subscription + * @return its URI + */ + public static String uriFor(@NonNull String subscriptionId) { + return URI_PREFIX + subscriptionId; + } + + /** + * A resource template covering every subscription. + * + * @param registry where open subscriptions live + * @return the resource specification to register with the server + */ + public static SyncResourceSpecification specification(@NonNull SubscriptionRegistry registry) { + Resource resource = + Resource.builder() + .uri(URI_PREFIX + "{subscriptionId}") + .name("Nostr subscription") + .description("Events buffered by an open subscription, updated as they arrive.") + .mimeType(MEDIA_TYPE) + .build(); + return new SyncResourceSpecification( + resource, (exchange, request) -> read(registry, request.uri())); + } + + private static ReadResourceResult read(SubscriptionRegistry registry, String uri) { + String subscriptionId = uri.substring(uri.lastIndexOf('/') + 1); + return registry + .find(subscriptionId) + .map(subscription -> new ReadResourceResult(List.of(contentsOf(uri, subscription)))) + .orElseGet( + () -> + new ReadResourceResult( + List.of( + new TextResourceContents( + uri, MEDIA_TYPE, "{\"error\":\"no such subscription\"}")))); + } + + /** + * Renders the buffer without consuming it, so several observers see the same events. + */ + private static TextResourceContents contentsOf(String uri, LiveSubscription subscription) { + StringBuilder json = new StringBuilder("{\"subscriptionId\":\"").append(subscription.id()); + json.append("\",\"waiting\":").append(subscription.depth()); + json.append(",\"droppedCount\":").append(subscription.droppedCount()); + json.append(",\"backlogDrained\":").append(subscription.backlogDrained()); + json.append('}'); + return new TextResourceContents(uri, MEDIA_TYPE, json.toString()); + } + + /** + * How many events a subscription is holding, for a caller rendering its own summary. + * + * @param events the events to describe + * @return the count + */ + public static int countOf(@NonNull List events) { + return events.size(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/CreateIdentityTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/CreateIdentityTool.java new file mode 100644 index 00000000..23a24310 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/CreateIdentityTool.java @@ -0,0 +1,80 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentitySummary; + +import java.util.List; +import java.util.Map; + +/** + * Creates a new identity, for "make me a throwaway account for this project". + * + *

Freely allowed, because creating a key is the one lifecycle operation that costs nothing to + * undo: an unwanted identity is discarded by removing it, and until it publishes anything it + * exists only on this machine. + * + *

The key is generated inside the vault and only the public half comes back, so an agent can + * create an account it is able to use but unable to leak. + */ +public final class CreateIdentityTool implements NostrTool { + + private final IdentityLifecycle lifecycle; + + /** + * @param lifecycle performs the keystore change + */ + public CreateIdentityTool(@NonNull IdentityLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + @Override + public String name() { + return "nostr_create_identity"; + } + + @Override + public String description() { + return "Create a new Nostr identity in this server's keystore and return its public key." + + " The private key stays on the server."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "alias", + Map.of( + "type", + "string", + "description", + "A short name such as 'project-bot': lowercase letters, digits and hyphens.")), + "required", + List.of("alias")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + IdentitySummary created = + lifecycle.create(new ToolArguments(request.arguments()).requireText("alias")); + return CallToolResult.builder() + .structuredContent( + Map.of( + "alias", created.alias(), + "publicKey", created.publicKey(), + "npub", created.npub())) + .addTextContent( + "Created identity '" + created.alias() + "' with public key " + created.npub() + ".") + .build(); + } catch (ToolException e) { + return e.asResult(); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ExportIdentityBackupTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ExportIdentityBackupTool.java new file mode 100644 index 00000000..6d203d09 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ExportIdentityBackupTool.java @@ -0,0 +1,90 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityLifecycle; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Writes an encrypted backup and reports only where it is. + * + *

The result carries a path and never the contents, which is what makes a backup safe to take + * from inside a conversation: the file is what protects the user against an accidental removal, + * while the key itself stays off the model's context entirely. + * + *

A passphrase is required rather than optional. An unencrypted backup is a plaintext private + * key sitting on disk, which converts a safety feature into the module's worst liability. + */ +public final class ExportIdentityBackupTool implements NostrTool { + + private final IdentityLifecycle lifecycle; + + /** + * @param lifecycle writes the backup + */ + public ExportIdentityBackupTool(@NonNull IdentityLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + @Override + public String name() { + return "nostr_export_identity_backup"; + } + + @Override + public String description() { + return "Write a passphrase-encrypted backup of an identity to a file on the server and" + + " return its path. The key itself is never returned."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "alias", Map.of("type", "string", "description", "The identity to back up."), + "path", Map.of("type", "string", "description", "Where the server should write the backup."), + "passphrase", + Map.of( + "type", + "string", + "description", + "Protects the backup file. Required, since an unencrypted backup is a" + + " plaintext private key on disk.")), + "required", + List.of("alias", "path", "passphrase")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + ToolArguments arguments = new ToolArguments(request.arguments()); + String alias = arguments.requireText("alias"); + Path written = + lifecycle.exportBackup( + alias, + Path.of(arguments.requireText("path")), + arguments.requireText("passphrase").toCharArray()); + return CallToolResult.builder() + .structuredContent(Map.of("alias", alias, "path", written.toString())) + .addTextContent( + "Wrote an encrypted backup of '" + + alias + + "' to " + + written + + ". Keep the passphrase safe: without it the backup cannot be restored.") + .build(); + } catch (ToolException e) { + return e.asResult(); + } catch (nostr.mcp.identity.IdentityUnknownException e) { + return ToolFailure.IDENTITY_UNKNOWN.with(e.getMessage()); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/FetchThreadTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/FetchThreadTool.java new file mode 100644 index 00000000..817ce883 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/FetchThreadTool.java @@ -0,0 +1,142 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Fetches a note together with the replies to it. + * + *

Two queries, because a thread is not something a relay can return in one: the note is found + * by id, and its replies are found by their {@code e} tag pointing back at it. Doing that in a + * tool rather than leaving it to the agent saves a round trip and, more importantly, saves the + * model from having to know how NIP-10 threading is encoded. + * + *

Only direct replies are fetched. Following the tree to arbitrary depth multiplies queries + * for diminishing returns, and a conversation an agent needs to summarise is nearly always the + * note plus what people said back. + */ +public final class FetchThreadTool implements NostrTool { + + private static final int TEXT_NOTE_KIND = 1; + private static final String REPLY_TAG = "e"; + + private final EventQuery eventQuery; + private final QueryLimits limits; + + /** + * @param eventQuery runs both queries + * @param limits the configured query bounds + */ + public FetchThreadTool(@NonNull EventQuery eventQuery, @NonNull QueryLimits limits) { + this.eventQuery = eventQuery; + this.limits = limits; + } + + @Override + public String name() { + return "nostr_fetch_thread"; + } + + @Override + public String description() { + return "Fetch a note and the replies to it, so a conversation can be read in order."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "eventId", + Map.of("type", "string", "description", "The note, as hex, note or nevent.")), + "required", + List.of("eventId")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return fetch( + NostrIdentifier.eventId( + "eventId", new ToolArguments(request.arguments()).requireText("eventId")) + .hex()); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult fetch(String eventId) { + Optional root = + eventQuery + .run( + EventFilter.builder().id(eventId).limit(1).build(), + 1, + limits.queryTimeout()) + .events() + .stream() + .findFirst(); + + if (root.isEmpty()) { + return CallToolResult.builder() + .structuredContent(Map.of("eventId", eventId, "found", false)) + .addTextContent("No note with that id was found on the configured relays.") + .build(); + } + + List replies = repliesTo(eventId); + return CallToolResult.builder() + .structuredContent( + Map.of( + "root", describe(root.get()), + "replies", replies.stream().map(FetchThreadTool::describe).toList(), + "replyCount", replies.size())) + .addTextContent( + replies.isEmpty() + ? "Found the note; nobody has replied to it." + : "Found the note and " + replies.size() + " repl" + (replies.size() == 1 ? "y." : "ies.")) + .build(); + } + + /** + * Finds replies oldest first, since a conversation read newest first is hard to follow. + */ + private List repliesTo(String eventId) { + return eventQuery + .run( + EventFilter.builder() + .kind(TEXT_NOTE_KIND) + .addTagFilter(REPLY_TAG, eventId) + .limit(limits.maxEventsPerQuery()) + .build(), + limits.maxEventsPerQuery(), + limits.queryTimeout()) + .events() + .stream() + .sorted(Comparator.comparing(GenericEvent::getCreatedAt)) + .toList(); + } + + private static Map describe(GenericEvent event) { + Map described = new LinkedHashMap<>(); + described.put("id", event.getId()); + described.put("pubkey", event.getPubKey() == null ? null : event.getPubKey().toHexString()); + described.put("created_at", event.getCreatedAt()); + described.put("content", event.getContent()); + return described; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetContactsTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetContactsTool.java new file mode 100644 index 00000000..e700c3e3 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetContactsTool.java @@ -0,0 +1,162 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.Contact; +import nostr.event.impl.ContactList; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.query.QueryResult; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Reads who someone follows. + * + *

A follow list is how an agent answers "what are the people I follow saying", which is the + * question behind most feed-shaped requests. Kind 3 is replaceable, so several relays may hold + * different generations and the newest wins. + * + *

Each entry keeps its relay hint and petname rather than only the key, because those are how + * a client finds someone it has never seen and shows a readable name without a global registry. + */ +public final class GetContactsTool implements NostrTool { + + private static final int CONTACT_LIST_KIND = 3; + + private final EventQuery eventQuery; + private final IdentityVault identityVault; + private final QueryLimits limits; + + /** + * @param eventQuery finds the list + * @param identityVault resolves whose list to read when none is named + * @param limits the configured query bounds + */ + public GetContactsTool( + @NonNull EventQuery eventQuery, + @NonNull IdentityVault identityVault, + @NonNull QueryLimits limits) { + this.eventQuery = eventQuery; + this.identityVault = identityVault; + this.limits = limits; + } + + @Override + public String name() { + return "nostr_get_contacts"; + } + + @Override + public String description() { + return "Read who someone follows (NIP-02). Defaults to this server's own identity."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "pubkey", + Map.of( + "type", + "string", + "description", + "Whose follow list to read, as hex or npub. Omit for your own.")), + "required", + List.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return read(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult read(ToolArguments arguments) { + String owner = resolveOwner(arguments); + QueryResult found = + eventQuery.run( + EventFilter.builder() + .author(owner) + .kind(CONTACT_LIST_KIND) + .limit(limits.maxEventsPerQuery()) + .build(), + limits.maxEventsPerQuery(), + limits.queryTimeout()); + + Optional contacts = + found.events().stream() + .max(Comparator.comparing(GenericEvent::getCreatedAt)) + .map(ContactList::from); + + return contacts + .map(list -> describe(owner, list)) + .orElseGet(() -> noList(owner, found)); + } + + private CallToolResult describe(String owner, ContactList contacts) { + List> described = contacts.getContacts().stream().map(GetContactsTool::describe).toList(); + return CallToolResult.builder() + .structuredContent(Map.of("pubkey", owner, "contacts", described, "count", described.size())) + .addTextContent("Follows " + described.size() + " account(s).") + .build(); + } + + private static Map describe(Contact contact) { + Map described = new LinkedHashMap<>(); + described.put("pubkey", contact.getPublicKey().toHexString()); + described.put("npub", contact.getPublicKey().toBech32String()); + described.put("relay", contact.findRelay().map(nostr.base.Relay::getUri).orElse("")); + described.put("petname", contact.findPetname().orElse("")); + return described; + } + + /** + * Reports an absent list as an answer, distinguishing it from an unfinished lookup. + * + *

Most keys have never published a follow list, which is a fact about the person rather + * than a failure, but a timed-out query is not the same thing and saying so prevents an agent + * concluding that somebody follows nobody. + */ + private CallToolResult noList(String owner, QueryResult found) { + return CallToolResult.builder() + .structuredContent(Map.of("pubkey", owner, "contacts", List.of(), "found", false)) + .addTextContent( + found.timedOut() + ? "No follow list arrived before the query timed out, so this key may have one." + : "This key has published no follow list on the configured relays.") + .build(); + } + + private String resolveOwner(ToolArguments arguments) { + return arguments + .text("pubkey") + .map(pubkey -> NostrIdentifier.publicKey("pubkey", pubkey).hex()) + .orElseGet( + () -> + identityVault + .defaultAlias() + .map(alias -> identityVault.publicKeyOf(alias).toHexString()) + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + "Give a 'pubkey', since this server has no default identity whose" + + " contacts could be meant."))); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetProfileTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetProfileTool.java new file mode 100644 index 00000000..73c81419 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/GetProfileTool.java @@ -0,0 +1,201 @@ +package nostr.mcp.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.directory.Nip05Resolver; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.query.QueryResult; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Answers "who is this?" for a public key or a NIP-05 address. + * + *

A profile is a kind-0 event whose content is a JSON string, so reading one means a query, a + * newest-wins choice, and a nested parse. Doing that in a dedicated tool keeps three awkward + * steps out of every agent's reasoning, and the encoding is the kind of detail a model gets + * subtly wrong. + */ +public final class GetProfileTool implements NostrTool { + + private static final int PROFILE_KIND = 0; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final EventQuery eventQuery; + private final Nip05Resolver nip05Resolver; + private final QueryLimits limits; + + /** + * @param eventQuery runs the lookup + * @param nip05Resolver turns an address into a key + * @param limits the configured query bounds + */ + public GetProfileTool( + @NonNull EventQuery eventQuery, + @NonNull Nip05Resolver nip05Resolver, + @NonNull QueryLimits limits) { + this.eventQuery = eventQuery; + this.nip05Resolver = nip05Resolver; + this.limits = limits; + } + + @Override + public String name() { + return "nostr_get_profile"; + } + + @Override + public String description() { + return "Look up someone's profile metadata by public key (hex or npub) or NIP-05 address" + + " such as alice@example.com."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "pubkey", Map.of("type", "string", "description", "The public key, as hex or npub."), + "nip05", + Map.of( + "type", + "string", + "description", + "A NIP-05 address such as alice@example.com, used when no pubkey is given."))); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return answer(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult answer(ToolArguments arguments) { + String publicKey = resolveSubject(arguments); + QueryResult result = + eventQuery.run( + EventFilter.builder().author(publicKey).kind(PROFILE_KIND).limit(limits.maxEventsPerQuery()).build(), + limits.maxEventsPerQuery(), + limits.queryTimeout()); + + return newestOf(result) + .map(profile -> found(publicKey, profile)) + .orElseGet(() -> notFound(publicKey, result)); + } + + /** + * Takes the subject from whichever argument the caller used. + * + *

Requiring exactly one is deliberate: given both, honouring one silently would answer a + * question the caller did not ask if the two disagree. + */ + private String resolveSubject(ToolArguments arguments) { + Optional pubkey = arguments.text("pubkey"); + Optional nip05 = arguments.text("nip05"); + if (pubkey.isPresent() && nip05.isPresent()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "Give either 'pubkey' or 'nip05', not both, so the answer is unambiguous"); + } + if (pubkey.isPresent()) { + return NostrIdentifier.publicKey("pubkey", pubkey.get()).hex(); + } + return nip05Resolver.resolve( + nip05.orElseThrow( + () -> ToolFailure.INVALID_ARGUMENT.raise("Give either 'pubkey' or 'nip05'"))); + } + + /** + * Chooses the newest profile event. + * + *

Kind 0 is replaceable, so several relays may return different generations of the same + * profile. Newest wins, which is what a client would show. + */ + private Optional newestOf(QueryResult result) { + return result.events().stream() + .max(java.util.Comparator.comparing(GenericEvent::getCreatedAt)); + } + + private CallToolResult found(String publicKey, GenericEvent profile) { + Map fields = parseContent(profile.getContent()); + Map structured = new LinkedHashMap<>(); + structured.put("pubkey", publicKey); + structured.put("npub", new nostr.base.PublicKey(publicKey).toBech32String()); + structured.put("updatedAt", profile.getCreatedAt()); + structured.putAll(fields); + return CallToolResult.builder() + .structuredContent(structured) + .addTextContent(summarise(fields, publicKey)) + .build(); + } + + /** + * Reports an absent profile as an answer, not an error. + * + *

Most keys have no kind-0 event, which is a fact about the person rather than a failure of + * the lookup. A timed-out query is different and says so, because "no profile" and "I did not + * finish looking" would otherwise be indistinguishable. + */ + private CallToolResult notFound(String publicKey, QueryResult result) { + String detail = + result.timedOut() + ? "No profile arrived before the query timed out, so this key may still have one." + : "No profile has been published for this key on the configured relays."; + return CallToolResult.builder() + .structuredContent(Map.of("pubkey", publicKey, "found", false, "timedOut", result.timedOut())) + .addTextContent(detail) + .build(); + } + + /** + * Reads the profile's JSON content, tolerating a malformed one. + * + *

Anyone can publish anything as kind 0, so a broken profile is a fact about the network, + * not an error in the lookup. Returning the raw content lets the agent see what was there + * instead of the tool failing on someone else's mistake. + */ + private Map parseContent(String content) { + if (content == null || content.isBlank()) { + return Map.of(); + } + try { + JsonNode parsed = MAPPER.readTree(content); + if (!parsed.isObject()) { + return Map.of("rawContent", content); + } + Map fields = new LinkedHashMap<>(); + parsed.properties().forEach(entry -> fields.put(entry.getKey(), asPlainValue(entry.getValue()))); + return fields; + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + return Map.of("rawContent", content); + } + } + + private Object asPlainValue(JsonNode value) { + return value.isTextual() ? value.asText() : value.toString(); + } + + private String summarise(Map fields, String publicKey) { + Object name = fields.getOrDefault("display_name", fields.get("name")); + if (name == null) { + return "A profile exists for " + publicKey + " but it names nobody."; + } + Object about = fields.get("about"); + return about == null ? String.valueOf(name) : name + ": " + about; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ImportIdentityTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ImportIdentityTool.java new file mode 100644 index 00000000..b936589a --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ImportIdentityTool.java @@ -0,0 +1,103 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.KeyImportSource; + +import java.util.List; +import java.util.Map; + +/** + * Imports an existing key that the server reads for itself. + * + *

This tool takes no key material, and that is its defining property rather than an + * inconvenience. An argument holding an {@code nsec} would put the user's key into the model's + * context, the host's conversation log, and probably a third-party inference API. So {@code + * source} names a location the server reads directly, and the model arranges an import it never + * observes. + */ +public final class ImportIdentityTool implements NostrTool { + + private final IdentityLifecycle lifecycle; + + /** + * @param lifecycle performs the keystore change + */ + public ImportIdentityTool(@NonNull IdentityLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + @Override + public String name() { + return "nostr_import_identity"; + } + + @Override + public String description() { + return "Import an existing Nostr key that the server reads itself. Never send key material" + + " to this tool; 'source' names where the server should read it from."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "alias", Map.of("type", "string", "description", "A short name for the identity."), + "source", + Map.of( + "type", + "string", + "description", + "Where the server reads the key from: 'file:/path/to/key'," + + " 'env:VARIABLE_NAME', or 'prompt'. Never the key itself.", + "examples", List.of("file:/home/user/key.txt", "env:NOSTR_IMPORT_KEY", "prompt")), + "shredSource", + Map.of( + "type", + "boolean", + "description", + "Overwrite and delete the key file after importing. Only applies to a file" + + " source.")), + "required", + List.of("alias", "source")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + ToolArguments arguments = new ToolArguments(request.arguments()); + String source = arguments.requireText("source"); + IdentitySummary imported = lifecycle.importFrom(arguments.requireText("alias"), source); + boolean shredded = shredIfAsked(arguments, source); + return CallToolResult.builder() + .structuredContent( + Map.of( + "alias", imported.alias(), + "publicKey", imported.publicKey(), + "npub", imported.npub(), + "sourceShredded", shredded)) + .addTextContent(describe(imported, shredded)) + .build(); + } catch (ToolException e) { + return e.asResult(); + } + } + + private boolean shredIfAsked(ToolArguments arguments, String source) { + return arguments.text("shredSource").map(Boolean::parseBoolean).orElse(false) + && KeyImportSource.shred(source); + } + + private String describe(IdentitySummary imported, boolean shredded) { + String summary = + "Imported identity '" + imported.alias() + "' with public key " + imported.npub() + "."; + return shredded ? summary + " The source file has been overwritten and deleted." : summary; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListIdentitiesTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListIdentitiesTool.java new file mode 100644 index 00000000..14768fcd --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListIdentitiesTool.java @@ -0,0 +1,74 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.IdentityVault; + +import java.util.List; +import java.util.Map; + +/** + * Reports which identities the server can sign with. + * + *

An agent needs this to name an identity at all, and it is the natural place for a key to + * leak, so what it returns is constrained by type rather than by care: {@link IdentitySummary} + * has no field that could hold one. + */ +public final class ListIdentitiesTool implements NostrTool { + + private final IdentityVault identityVault; + + /** + * @param identityVault the keys this server holds + */ + public ListIdentitiesTool(@NonNull IdentityVault identityVault) { + this.identityVault = identityVault; + } + + @Override + public String name() { + return "nostr_list_identities"; + } + + @Override + public String description() { + return "List the identities this server can sign with, by alias and public key."; + } + + @Override + public Map inputSchema() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + List identities = identityVault.list(); + return CallToolResult.builder() + .structuredContent( + Map.of( + "identities", identities, + "default", identityVault.defaultAlias().orElse(""))) + .addTextContent(summarise(identities)) + .build(); + } + + /** + * States what an agent must do next when the answer is ambiguous. + * + *

Several identities with no default is the condition that makes signing fail, so saying so + * here saves the agent discovering it by posting from the wrong account. + */ + private String summarise(List identities) { + if (identities.isEmpty()) { + return "No identities are configured; this server cannot sign."; + } + return identityVault + .defaultAlias() + .map(alias -> identities.size() + " identities, signing as '" + alias + "' by default") + .orElse( + identities.size() + + " identities and no default; name one when signing or signing will fail"); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListRelaysTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListRelaysTool.java new file mode 100644 index 00000000..8755a3fc --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListRelaysTool.java @@ -0,0 +1,88 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.ConnectionState; +import nostr.mcp.relay.RelayDirectory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reports the configured relays and whether each is currently carrying traffic. + * + *

This is the module's tracer bullet: it needs no keystore, writes nothing, and touches every + * layer from transport to the SDK, so it proves the wiring end to end. It is also genuinely + * useful, because "why did my note only reach two relays" is unanswerable without it. + */ +public final class ListRelaysTool implements NostrTool { + + private final RelayDirectory relayDirectory; + private final RelayPool relayPool; + + /** + * @param relayDirectory the logical relay names an agent can use + * @param relayPool the live connections whose state is reported + */ + public ListRelaysTool(@NonNull RelayDirectory relayDirectory, @NonNull RelayPool relayPool) { + this.relayDirectory = relayDirectory; + this.relayPool = relayPool; + } + + @Override + public String name() { + return "nostr_list_relays"; + } + + @Override + public String description() { + return "List the configured Nostr relays and their current connection state."; + } + + @Override + public Map inputSchema() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + List> relays = new ArrayList<>(); + for (String relayUri : relayDirectory.allRelayUris()) { + relays.add(describe(relayUri)); + } + return CallToolResult.builder() + .structuredContent(Map.of("relays", relays, "names", relayDirectory.names())) + .addTextContent(summarise(relays)) + .build(); + } + + private Map describe(String relayUri) { + Map relay = new LinkedHashMap<>(); + relay.put("uri", relayUri); + relay.put("state", stateOf(relayUri)); + return relay; + } + + /** + * The relay's state, or {@code UNREACHABLE} when the pool holds no connection for it. + * + *

A relay the pool never managed to connect to has no state of its own, and reporting it as + * absent would hide the very thing an operator is asking about. + */ + private String stateOf(String relayUri) { + return relayPool + .getConnectionState(relayUri) + .map(ConnectionState::name) + .orElse("UNREACHABLE"); + } + + private String summarise(List> relays) { + long connected = + relays.stream().filter(relay -> ConnectionState.CONNECTED.name().equals(relay.get("state"))).count(); + return connected + " of " + relays.size() + " relays connected"; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListSubscriptionsTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListSubscriptionsTool.java new file mode 100644 index 00000000..fa8067e2 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ListSubscriptionsTool.java @@ -0,0 +1,87 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.subscription.LiveSubscription; +import nostr.mcp.subscription.SubscriptionRegistry; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reports every open subscription and how healthy it is. + * + *

Needed because a subscription can degrade without failing: relays drop out, buffers + * overflow, and the agent that opened it may be a different conversation from the one now + * reading. This is how an agent discovers what it is already watching, and whether what it is + * receiving is the whole picture. + */ +public final class ListSubscriptionsTool implements NostrTool { + + private final SubscriptionRegistry subscriptions; + + /** + * @param subscriptions where open subscriptions live + */ + public ListSubscriptionsTool(@NonNull SubscriptionRegistry subscriptions) { + this.subscriptions = subscriptions; + } + + @Override + public String name() { + return "nostr_list_subscriptions"; + } + + @Override + public String description() { + return "List the open subscriptions, how many events are waiting in each, and whether any" + + " relays have dropped out."; + } + + @Override + public Map inputSchema() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + List open = subscriptions.list(); + return CallToolResult.builder() + .structuredContent( + Map.of("subscriptions", open.stream().map(ListSubscriptionsTool::describe).toList())) + .addTextContent(summarise(open)) + .build(); + } + + private static Map describe(LiveSubscription subscription) { + Map described = new LinkedHashMap<>(); + described.put("subscriptionId", subscription.id()); + described.put("filter", subscription.filter().toString()); + described.put("waiting", subscription.depth()); + described.put("droppedCount", subscription.droppedCount()); + described.put("backlogDrained", subscription.backlogDrained()); + described.put("relays", List.copyOf(subscription.subscribedRelays())); + described.put("failedRelays", subscription.failures()); + return described; + } + + private String summarise(List open) { + if (open.isEmpty()) { + return "No open subscriptions. Start one with nostr_subscribe."; + } + StringBuilder summary = new StringBuilder(); + open.forEach( + subscription -> + summary + .append(subscription.id()) + .append(": ") + .append(subscription.depth()) + .append(" waiting") + .append(subscription.droppedCount() > 0 ? ", " + subscription.droppedCount() + " dropped" : "") + .append(subscription.failures().isEmpty() ? "" : ", relays down: " + subscription.failures().keySet()) + .append('\n')); + return summary.toString().strip(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrTool.java new file mode 100644 index 00000000..1038e1e8 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrTool.java @@ -0,0 +1,50 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +import java.util.Map; + +/** + * One capability an agent can invoke. + * + *

A tool owns its own name, schema and behaviour, so adding one means adding a class rather + * than editing a dispatcher. That matters more here than it usually would: the tool surface is + * what an agent sees and reasons about, and a registry that grows by accretion is one where a + * tool can be added without anyone deciding whether an agent should have it. + * + *

Implementations must not throw for ordinary failures. An agent cannot act on a stack trace, + * so a tool reports trouble as a result carrying a stable code, which is what + * {@link ToolFailure} produces. + */ +public interface NostrTool { + + /** + * The name an agent calls, namespaced {@code nostr_*} so it reads clearly in a tool list. + * + * @return the tool name + */ + String name(); + + /** + * What this tool does, written for a model deciding whether to call it. + * + * @return a one-line description + */ + String description(); + + /** + * The JSON Schema for this tool's arguments. + * + * @return the schema, empty when the tool takes none + */ + Map inputSchema(); + + /** + * Run the tool. + * + * @param request the agent's call, carrying its arguments + * @return the result, structured for a program and summarised for a reader + */ + CallToolResult call(CallToolRequest request); +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrToolRegistry.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrToolRegistry.java new file mode 100644 index 00000000..fde43c2e --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/NostrToolRegistry.java @@ -0,0 +1,79 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import lombok.NonNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The single place that decides which tools an agent can see. + * + *

Concentrating registration here is what makes the surface reviewable: policy and + * single-identity mode both work by not registering a tool rather than by rejecting a + * call at runtime, and a tool an agent cannot see is a tool it cannot misuse. That only holds if + * there is one list to look at. + * + *

Registering the same name twice is rejected rather than silently resolved, since which of + * two tools an agent got would otherwise depend on iteration order. + */ +public final class NostrToolRegistry { + + private final Map toolsByName = new LinkedHashMap<>(); + + /** + * Add a tool to the surface. + * + * @param tool the tool to register + * @return this registry + * @throws IllegalStateException if a tool of that name is already registered + */ + public NostrToolRegistry register(@NonNull NostrTool tool) { + NostrTool existing = toolsByName.putIfAbsent(tool.name(), tool); + if (existing != null) { + throw new IllegalStateException("A tool named " + tool.name() + " is already registered"); + } + return this; + } + + /** + * The registered tool names, in registration order. + * + * @return the names an agent will see + */ + public List registeredNames() { + return List.copyOf(toolsByName.keySet()); + } + + /** + * The registered tools themselves. + * + * @return the tools an agent will see, in registration order + */ + public List tools() { + return List.copyOf(toolsByName.values()); + } + + /** + * Render the surface as MCP tool specifications. + * + * @return one specification per registered tool + */ + public List toSpecifications() { + return toolsByName.values().stream().map(NostrToolRegistry::toSpecification).toList(); + } + + private static SyncToolSpecification toSpecification(NostrTool tool) { + return SyncToolSpecification.builder() + .tool( + Tool.builder() + .name(tool.name()) + .description(tool.description()) + .inputSchema(tool.inputSchema()) + .build()) + .callHandler((exchange, request) -> tool.call(request)) + .build(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishEventTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishEventTool.java new file mode 100644 index 00000000..10edf5ee --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishEventTool.java @@ -0,0 +1,106 @@ +package nostr.mcp.tool; + +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.write.WriteGuard; + +import java.util.List; +import java.util.Map; + +/** + * Publishes an event of any kind: the escape hatch. + * + *

Nostr's kinds are open-ended and new NIPs arrive continuously, so a server offering only the + * kinds someone thought to wrap would age badly. This tool lets an agent use a NIP this module + * has never heard of, at the cost of the caller composing the event itself. + * + *

It goes through the same guard as every other write. An escape hatch that bypassed + * confirmation would make the safety model advisory, since anything refused elsewhere could be + * published here instead. + */ +public final class PublishEventTool extends PublishingTool { + + private static final int MAX_PREVIEW_LENGTH = 500; + + /** + * @param writeGuard the point every write passes through + */ + public PublishEventTool(WriteGuard writeGuard) { + super(writeGuard); + } + + @Override + public String name() { + return "nostr_publish_event"; + } + + @Override + public String description() { + return "Publish an event of any kind, for NIPs without a dedicated tool. Anything published" + + " is public and cannot be reliably deleted."; + } + + @Override + protected Map writeSpecificProperties() { + return Map.of( + "kind", Map.of("type", "integer", "description", "The NIP-01 event kind."), + "content", Map.of("type", "string", "description", "The event content."), + "tags", + Map.of( + "type", + "array", + "description", + "Tags as arrays, such as [[\"p\",\"\"],[\"t\",\"nostr\"]].", + "items", Map.of("type", "array", "items", Map.of("type", "string")))); + } + + @Override + protected List writeSpecificRequired() { + return List.of("kind"); + } + + @Override + protected GenericEvent buildEvent(ToolArguments arguments) { + GenericEvent event = + GenericEvent.builder() + .kind( + arguments + .integer("kind") + .orElseThrow(() -> ToolFailure.INVALID_ARGUMENT.raise("'kind' is required"))) + .content(arguments.text("content").orElse("")) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + tagsFrom(arguments).forEach(event::addTag); + return event; + } + + /** + * Reads the nested arrays NIP-01 uses for tags. + * + *

A tag is a list whose first element names it, so a malformed entry is refused by name + * rather than being published as something the caller did not intend. + */ + private List tagsFrom(ToolArguments arguments) { + return arguments.nestedTexts("tags").stream() + .map( + values -> { + if (values.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "Every tag needs at least a name, such as [\"p\", \"\"]"); + } + return new GenericTag(values.getFirst(), values.subList(1, values.size())); + }) + .toList(); + } + + @Override + protected String describeForPreview(GenericEvent event) { + String content = event.getContent() == null ? "" : event.getContent(); + String shown = + content.length() > MAX_PREVIEW_LENGTH + ? content.substring(0, MAX_PREVIEW_LENGTH) + "..." + : content; + return "kind " + event.getKind() + ", " + event.getTags().size() + " tag(s)\n" + shown; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishNoteTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishNoteTool.java new file mode 100644 index 00000000..2af0b15f --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishNoteTool.java @@ -0,0 +1,101 @@ +package nostr.mcp.tool; + +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.write.WriteGuard; + +import java.util.List; +import java.util.Map; + +/** + * Posts a text note, the thing an agent is usually asked to do. + * + *

Kind 1 has its own tool rather than being a case of the general one, because the common + * action should be the easy one: a model asked to "post that" should reach for a tool whose only + * argument is the text, not compose a raw event and choose a kind number. + */ +public final class PublishNoteTool extends PublishingTool { + + private static final int TEXT_NOTE_KIND = 1; + private static final String REPLY_TAG = "e"; + private static final String MENTION_TAG = "p"; + private static final String REPLY_MARKER = "reply"; + + /** + * @param writeGuard the point every write passes through + */ + public PublishNoteTool(WriteGuard writeGuard) { + super(writeGuard); + } + + @Override + public String name() { + return "nostr_publish_note"; + } + + @Override + public String description() { + return "Publish a public text note to Nostr. Anything published is public and cannot be" + + " reliably deleted."; + } + + @Override + protected Map writeSpecificProperties() { + return Map.of( + "content", Map.of("type", "string", "description", "The text of the note."), + "replyTo", + Map.of( + "type", + "string", + "description", + "The note this replies to, as hex, note or nevent. Omit for a new note."), + "mentions", + Map.of( + "type", "array", + "description", "Public keys to mention, as hex or npub.", + "items", Map.of("type", "string"))); + } + + @Override + protected List writeSpecificRequired() { + return List.of("content"); + } + + @Override + protected GenericEvent buildEvent(ToolArguments arguments) { + GenericEvent note = + GenericEvent.builder() + .kind(TEXT_NOTE_KIND) + .content(arguments.requireText("content")) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + addThreadingTags(note, arguments); + return note; + } + + /** + * Marks a reply and its mentions the way NIP-10 expects. + * + *

Without the {@code e} tag a reply is an unrelated note that happens to mention the same + * subject, and every client will show it detached from the conversation it answers. Mentioned + * keys get a {@code p} tag, which is how the person mentioned is notified at all. + */ + private void addThreadingTags(GenericEvent note, ToolArguments arguments) { + arguments + .text("replyTo") + .map(replyTo -> NostrIdentifier.eventId("replyTo", replyTo).hex()) + .ifPresent( + eventId -> + note.addTag(new GenericTag(REPLY_TAG, List.of(eventId, "", REPLY_MARKER)))); + arguments.texts("mentions").stream() + .map(mention -> NostrIdentifier.publicKey("mentions", mention).hex()) + .forEach(pubkey -> note.addTag(new GenericTag(MENTION_TAG, List.of(pubkey)))); + } + + @Override + protected String describeForPreview(GenericEvent event) { + return event.getContent(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishingTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishingTool.java new file mode 100644 index 00000000..acad5a18 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/PublishingTool.java @@ -0,0 +1,182 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.client.relay.PublishResult; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.write.PendingWrite; +import nostr.mcp.write.WriteGuard; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * The shape every publishing tool shares: describe an event, then let the guard publish it. + * + *

Confirmation, rate limiting, identity resolution and result rendering are identical for + * every write, and only the event differs. A subclass therefore says what to publish and nothing + * about how, which is what keeps a new write tool from quietly omitting a guard. + */ +public abstract class PublishingTool implements NostrTool { + + private static final String CONFIRMATION_ARGUMENT = "confirmationToken"; + + private final WriteGuard writeGuard; + + /** + * @param writeGuard the single point every write passes through + */ + protected PublishingTool(@NonNull WriteGuard writeGuard) { + this.writeGuard = writeGuard; + } + + /** + * Build the event this tool publishes. + * + * @param arguments the caller's arguments + * @return the unsigned event, which the guard signs + */ + protected abstract GenericEvent buildEvent(ToolArguments arguments); + + /** + * Describe the event for the agent to confirm. + * + * @param event the signed event + * @return a human-readable preview + */ + protected abstract String describeForPreview(GenericEvent event); + + /** + * The arguments this tool takes, beyond the ones every write shares. + * + * @return the tool-specific schema properties + */ + protected abstract Map writeSpecificProperties(); + + /** + * The arguments this tool requires when publishing for the first time. + * + * @return the required argument names + */ + protected abstract List writeSpecificRequired(); + + /** + * The schema a host shows the model. + * + *

A bound server omits {@code identity} rather than documenting that it is ignored. The + * whole point of binding is that there is no name to give, and an argument with exactly one + * acceptable value is an invitation to pass a different one. + */ + @Override + public final Map inputSchema() { + Map properties = new LinkedHashMap<>(writeSpecificProperties()); + if (!writeGuard.bindsOneIdentity()) { + properties.put( + "identity", Map.of("type", "string", "description", "Alias to publish as. Omit to use the default.")); + } + if (writeGuard.requiresConfirmation()) { + properties.put( + CONFIRMATION_ARGUMENT, + Map.of( + "type", + "string", + "description", + "Token from a previous preview. Omit to preview; supply it to publish.")); + } + return Map.of("type", "object", "properties", properties, "required", List.of()); + } + + @Override + public final CallToolResult call(CallToolRequest request) { + try { + return publishOrPreview(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + /** + * Runs whichever half of the conversation the caller is in. + * + *

The token decides. Its presence means the agent has already seen a preview and is asking + * to send that exact event, so the arguments are not read again: re-reading them would let the + * content change between what was shown and what is published. + */ + private CallToolResult publishOrPreview(ToolArguments arguments) { + Optional token = arguments.text(CONFIRMATION_ARGUMENT); + if (token.isPresent()) { + return published(writeGuard.publishConfirmed(token.get())); + } + PendingWrite pending = writeGuard.prepare(buildEvent(arguments), arguments.text("identity")); + return writeGuard.requiresConfirmation() + ? preview(pending) + : published(writeGuard.publishDirectly(pending)); + } + + private CallToolResult preview(PendingWrite pending) { + return CallToolResult.builder() + .structuredContent( + Map.of( + "status", "awaiting-confirmation", + CONFIRMATION_ARGUMENT, pending.token(), + "identity", pending.identityAlias(), + "eventId", String.valueOf(pending.event().getId()), + "kind", pending.event().getKind())) + .addTextContent( + "Nothing has been published yet. This would post as '" + + pending.identityAlias() + + "':\n\n" + + describeForPreview(pending.event()) + + "\n\nPublishing to Nostr is public and cannot be reliably undone. To go ahead," + + " call this tool again with " + + CONFIRMATION_ARGUMENT + + "='" + + pending.token() + + "'.") + .build(); + } + + /** + * Reports the per-relay outcome, treating any acceptance as success. + * + *

An event accepted by one relay is on the network. Calling a partial success a failure + * would invite the agent to retry a write that already landed, and on a permanent medium a + * duplicate is worse than an incomplete send. + */ + private CallToolResult published(PublishResult result) { + List accepted = result.getAcceptingRelays(); + Map structured = new LinkedHashMap<>(); + structured.put("eventId", result.getEventId()); + structured.put("acceptedBy", accepted); + structured.put( + "failures", + result.getFailures().stream() + .map( + failure -> + Map.of( + "relay", failure.relayUri(), + "status", failure.status().name(), + "reason", failure.findReason().orElse(""))) + .toList()); + return CallToolResult.builder() + .structuredContent(structured) + .addTextContent(summarise(result, accepted)) + .build(); + } + + private String summarise(PublishResult result, List accepted) { + StringBuilder summary = + new StringBuilder("Published ").append(result.getEventId()).append(" to ").append(accepted.size()); + summary.append(accepted.size() == 1 ? " relay" : " relays"); + if (!result.getFailures().isEmpty()) { + summary.append(". It did not reach "); + summary.append(result.getFailures().stream().map(failure -> failure.relayUri()).toList()); + summary.append(", but it is published and should not be sent again"); + } + return summary.append('.').toString(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/QueryEventsTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/QueryEventsTool.java new file mode 100644 index 00000000..f430a218 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/QueryEventsTool.java @@ -0,0 +1,162 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.EventFilterArguments; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.query.QueryResult; + +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Answers a question about what is on the relays. + * + *

The general read primitive: everything an agent wants to know about existing events is a + * filter over authors, kinds, tags and time. More specific tools exist where the decoding is + * non-obvious, such as profiles, but this is the one that makes the module useful for questions + * nobody anticipated. + */ +public final class QueryEventsTool implements NostrTool { + + private final EventQuery eventQuery; + private final QueryLimits limits; + private final Clock clock; + + /** + * @param eventQuery runs the query against the relays + * @param limits the configured bounds on size and duration + * @param clock what "now" means when resolving relative times + */ + public QueryEventsTool( + @NonNull EventQuery eventQuery, @NonNull QueryLimits limits, @NonNull Clock clock) { + this.eventQuery = eventQuery; + this.limits = limits; + this.clock = clock; + } + + @Override + public String name() { + return "nostr_query_events"; + } + + @Override + public String description() { + return "Query the relays for events matching a filter. Authors accept hex or npub; times" + + " accept a relative age such as '24h', an ISO-8601 timestamp, or a date."; + } + + @Override + public Map inputSchema() { + return EventFilterArguments.schemaWith( + Map.of( + "limit", + Map.of( + "type", + "integer", + "description", + "Most events to return (capped at " + limits.maxEventsPerQuery() + ")."))); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return answer(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult answer(ToolArguments arguments) { + QueryResult result = + eventQuery.run(filterFrom(arguments), requestedLimit(arguments), limits.queryTimeout()); + return CallToolResult.builder() + .structuredContent( + Map.of( + "events", result.events().stream().map(QueryEventsTool::describe).toList(), + "count", result.events().size(), + "truncated", result.truncated(), + "timedOut", result.timedOut())) + .addTextContent(summarise(result)) + .build(); + } + + /** + * Asks the relay for one more event than will be returned. + * + *

A relay honours the filter's own limit, so asking for exactly the number wanted makes a + * complete answer and a truncated one arrive identically: the stream simply ends. Requesting + * one extra means an answer that overflows is visibly an overflow, and the extra event is + * discarded rather than shown. + */ + private EventFilter filterFrom(ToolArguments arguments) { + return EventFilterArguments.toFilterBuilder(arguments, clock) + .limit(requestedLimit(arguments) + 1) + .build(); + } + + /** + * Caps the caller's limit at the configured maximum. + * + *

Capped rather than refused: an agent asking for more than the deployment allows has made + * no error, and failing the call would leave it with nothing where a smaller answer is useful. + * The result says whether the cap actually bit. + */ + private int requestedLimit(ToolArguments arguments) { + return arguments + .integer("limit") + .filter(limit -> limit > 0) + .map(limit -> Math.min(limit, limits.maxEventsPerQuery())) + .orElseGet(limits::maxEventsPerQuery); + } + + private static Map describe(GenericEvent event) { + Map described = new LinkedHashMap<>(); + described.put("id", event.getId()); + described.put("pubkey", event.getPubKey() == null ? null : event.getPubKey().toHexString()); + described.put("kind", event.getKind()); + described.put("created_at", event.getCreatedAt()); + described.put("content", event.getContent()); + return described; + } + + /** + * States plainly when an answer is partial. + * + *

An agent that cannot tell "nothing matched" from "I stopped looking" will report the + * first when the truth was the second, so both bounds are named in the text the model reads. + */ + private String summarise(QueryResult result) { + if (result.events().isEmpty()) { + return result.timedOut() + ? "No events arrived before the query timed out. The relays may still be replaying;" + + " try again or narrow the filter." + : "No events matched."; + } + StringBuilder summary = new StringBuilder(); + summary.append("Found ").append(result.events().size()).append(" event"); + if (result.events().size() != 1) { + summary.append('s'); + } + summary.append(" across ").append(result.relayCount()).append(" relay"); + if (result.relayCount() != 1) { + summary.append('s'); + } + summary.append('.'); + if (result.truncated()) { + summary.append(" The limit was reached, so there may be more; narrow the filter to see the rest."); + } + if (result.timedOut()) { + summary.append(" The relays had not finished replaying, so this may be incomplete."); + } + return summary.toString(); + } + +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadDirectMessagesTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadDirectMessagesTool.java new file mode 100644 index 00000000..55d33763 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadDirectMessagesTool.java @@ -0,0 +1,175 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.ChatMessage; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.TimeArgument; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.social.McpDirectMessageService; + +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reads private messages addressed to one identity. + * + *

Gift wraps are addressed to a single-use key and carry a randomised timestamp, so they are + * found by the {@code p} tag naming the recipient rather than by author or time. Unwrapping is + * attempted for each, and one that cannot be opened is skipped rather than failing the call: a + * relay will happily return wraps addressed to somebody else. + * + *

Decryption is opt-in per identity. Reading correspondence into a conversation puts it in + * the host's logs and probably a third-party API, so it stays a decision for the person whose + * messages they are. + */ +public final class ReadDirectMessagesTool implements NostrTool { + + private static final int GIFT_WRAP_KIND = 1059; + private static final String RECIPIENT_TAG = "p"; + + private final McpDirectMessageService directMessages; + private final IdentityVault identityVault; + private final EventQuery eventQuery; + private final QueryLimits limits; + private final Clock clock; + + /** + * @param directMessages unwraps the messages + * @param identityVault resolves which identity to read for + * @param eventQuery finds the wraps + * @param limits the configured query bounds + * @param clock what "now" means for relative times + */ + public ReadDirectMessagesTool( + @NonNull McpDirectMessageService directMessages, + @NonNull IdentityVault identityVault, + @NonNull EventQuery eventQuery, + @NonNull QueryLimits limits, + @NonNull Clock clock) { + this.directMessages = directMessages; + this.identityVault = identityVault; + this.eventQuery = eventQuery; + this.limits = limits; + this.clock = clock; + } + + @Override + public String name() { + return "nostr_read_direct_messages"; + } + + @Override + public String description() { + return "Read private messages sent to one of this server's identities. Must be enabled per" + + " identity, since it brings private correspondence into this conversation."; + } + + /** + * A bound server omits {@code identity}, since it reads for exactly one. + */ + @Override + public Map inputSchema() { + Map properties = new java.util.LinkedHashMap<>(); + if (!identityVault.binding().isBound()) { + properties.put( + "identity", Map.of("type", "string", "description", "Alias to read for. Omit for the default.")); + } + properties.put( + "since", + Map.of( + "type", + "string", + "description", + "Only messages received after this time, such as '24h'. Note that gift wraps carry" + + " randomised timestamps, so this is approximate.")); + return Map.of("type", "object", "properties", properties, "required", List.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return read(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult read(ToolArguments arguments) { + String alias = resolveIdentity(arguments); + if (!directMessages.mayDecrypt(alias)) { + throw ToolFailure.WRITE_FORBIDDEN.raise( + "Reading '" + + alias + + "' private messages is not enabled on this server. Start it with" + + " -Dnostr.mcp.dm.decrypt-for=" + alias + " if the owner wants that."); + } + List> messages = unwrap(alias, findWraps(alias, arguments)); + return CallToolResult.builder() + .structuredContent(Map.of("messages", messages, "count", messages.size())) + .addTextContent( + messages.isEmpty() + ? "No private messages found for '" + alias + "'." + : "Found " + messages.size() + " private message(s) for '" + alias + "'.") + .build(); + } + + private List findWraps(String alias, ToolArguments arguments) { + EventFilter.Builder filter = + EventFilter.builder() + .kind(GIFT_WRAP_KIND) + .addTagFilter(RECIPIENT_TAG, directMessages.publicKeyOf(alias).toHexString()) + .limit(limits.maxEventsPerQuery()); + arguments + .text("since") + .ifPresent(since -> filter.since(TimeArgument.toUnixSeconds("since", since, clock))); + return eventQuery.run(filter.build(), limits.maxEventsPerQuery(), limits.queryTimeout()).events(); + } + + /** + * Opens each wrap, skipping any that will not open. + * + *

A relay returns whatever matches the tag, including wraps this identity cannot decrypt, + * so failing on the first would make the tool unusable rather than reporting a real problem. + */ + private List> unwrap(String alias, List wraps) { + return wraps.stream() + .map(wrap -> tryUnwrap(alias, wrap)) + .filter(java.util.Objects::nonNull) + .toList(); + } + + private Map tryUnwrap(String alias, GenericEvent wrap) { + try { + ChatMessage message = directMessages.read(alias, wrap); + Map described = new LinkedHashMap<>(); + described.put("from", message.getSender() == null ? null : message.getSender().toHexString()); + described.put("content", message.getContent()); + described.put("receivedWrapId", wrap.getId()); + return described; + } catch (RuntimeException notForUs) { + return null; + } + } + + private String resolveIdentity(ToolArguments arguments) { + return arguments + .text("identity") + .orElseGet( + () -> + identityVault + .defaultAlias() + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + "This server holds several identities and none is the default." + + " Name one in 'identity'."))); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadSubscriptionTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadSubscriptionTool.java new file mode 100644 index 00000000..77b48067 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ReadSubscriptionTool.java @@ -0,0 +1,119 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.subscription.LiveSubscription; +import nostr.mcp.subscription.SubscriptionRegistry; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Collects what a subscription has caught since it was last read. + * + *

Reading drains, so polling repeatedly yields only what is new. That is a command that also + * answers, which is usually worth avoiding, but the alternative fills an agent's context with + * the same events on every poll until nothing else fits. + */ +public final class ReadSubscriptionTool implements NostrTool { + + private final SubscriptionRegistry subscriptions; + + /** + * @param subscriptions where open subscriptions live + */ + public ReadSubscriptionTool(@NonNull SubscriptionRegistry subscriptions) { + this.subscriptions = subscriptions; + } + + @Override + public String name() { + return "nostr_read_subscription"; + } + + @Override + public String description() { + return "Collect the events a subscription has received since you last read it. Reading" + + " empties it, so each event is returned once."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "subscriptionId", + Map.of("type", "string", "description", "The id returned by nostr_subscribe.")), + "required", + List.of("subscriptionId")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + LiveSubscription subscription = + subscriptions.require(new ToolArguments(request.arguments()).requireText("subscriptionId")); + long droppedBefore = subscription.droppedCount(); + List events = subscription.drain(); + return CallToolResult.builder() + .structuredContent( + Map.of( + "subscriptionId", subscription.id(), + "events", events.stream().map(ReadSubscriptionTool::describe).toList(), + "count", events.size(), + "droppedCount", droppedBefore, + "backlogDrained", subscription.backlogDrained())) + .addTextContent(summarise(subscription, events, droppedBefore)) + .build(); + } catch (ToolException e) { + return e.asResult(); + } + } + + private static Map describe(GenericEvent event) { + Map described = new LinkedHashMap<>(); + described.put("id", event.getId()); + described.put("pubkey", event.getPubKey() == null ? null : event.getPubKey().toHexString()); + described.put("kind", event.getKind()); + described.put("created_at", event.getCreatedAt()); + described.put("content", event.getContent()); + return described; + } + + /** + * Says what arrived, and warns about anything that did not. + * + *

Two things an agent cannot infer are stated outright: that the backlog is still replaying, + * so an empty read is not yet an empty feed, and that events were dropped, so what it has is a + * sample rather than the whole story. + */ + private String summarise( + LiveSubscription subscription, List events, long droppedCount) { + StringBuilder summary = new StringBuilder(); + if (events.isEmpty()) { + summary.append( + subscription.backlogDrained() + ? "Nothing new since the last read." + : "Nothing yet: the relays are still replaying their stored events."); + } else { + summary.append("Received ").append(events.size()).append(events.size() == 1 ? " event." : " events."); + } + if (droppedCount > 0) { + summary + .append(" ") + .append(droppedCount) + .append(" event(s) were dropped because the buffer filled up, so this is not the whole") + .append(" story; read more often or narrow the filter."); + } + if (!subscription.failures().isEmpty()) { + summary.append(" Some relays have dropped out: ").append(subscription.failures().keySet()).append('.'); + } + return summary.toString(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/RelayInfoTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RelayInfoTool.java new file mode 100644 index 00000000..1d6f5221 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RelayInfoTool.java @@ -0,0 +1,152 @@ +package nostr.mcp.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.directory.WellKnownJson; +import nostr.mcp.relay.RelayDirectory; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reports what a relay says about itself. + * + *

A relay's NIP-11 document states its limits, its policies and what it charges, which is how + * an agent can answer "why was my note rejected" or "can I post something this long here" before + * trying. Without it the only way to learn a relay's rules is to break one. + */ +public final class RelayInfoTool implements NostrTool { + + private static final String NIP11_MEDIA_TYPE = "application/nostr+json"; + + private final RelayDirectory relayDirectory; + private final WellKnownJson wellKnownJson; + + /** + * @param relayDirectory resolves a configured relay name to its URI + * @param wellKnownJson fetches the document + */ + public RelayInfoTool(@NonNull RelayDirectory relayDirectory, @NonNull WellKnownJson wellKnownJson) { + this.relayDirectory = relayDirectory; + this.wellKnownJson = wellKnownJson; + } + + @Override + public String name() { + return "nostr_relay_info"; + } + + @Override + public String description() { + return "Read a relay's NIP-11 document: its name, policies, limits and supported NIPs." + + " Accepts a configured relay name or a wss:// URI."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "relay", + Map.of( + "type", + "string", + "description", + "A configured relay name or a wss:// URI. Known names: " + relayDirectory.names())), + "required", + List.of("relay")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return describe(new ToolArguments(request.arguments()).requireText("relay")); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult describe(String relay) { + String relayUri = resolve(relay); + JsonNode document = wellKnownJson.fetch(httpUriOf(relayUri), NIP11_MEDIA_TYPE, relayUri); + return CallToolResult.builder() + .structuredContent(structuredFrom(relayUri, document)) + .addTextContent(summarise(relayUri, document)) + .build(); + } + + /** + * Accepts either a configured name or a literal URI. + * + *

An agent that has just called {@code nostr_list_relays} holds names, while one repeating + * something a user said holds a URI. Refusing either would make the tool awkward to reach. + */ + private String resolve(String relay) { + List resolved = relayDirectory.resolve(List.of(relay)); + String candidate = resolved.isEmpty() ? relay : resolved.getFirst(); + refuseUnlessAddressable(relay, candidate); + return candidate; + } + + /** + * Refuses something that is neither a known name nor a usable relay address. + * + *

The directory passes an unrecognised string through as though it were a URI, since a + * caller may legitimately name a relay that was never configured. That leaves this tool as the + * place where a value that is neither is caught: without the check a typo reaches the HTTP + * client and surfaces as an {@code IllegalArgumentException} about an undefined scheme, which + * tells the agent nothing it can act on. + */ + private void refuseUnlessAddressable(String relay, String candidate) { + if (candidate.startsWith("ws://") || candidate.startsWith("wss://")) { + return; + } + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + + relay + + "' is not a configured relay name, and is not a relay URI either; a relay URI" + + " starts with wss:// or ws://. Known names: " + + relayDirectory.names()); + } + + /** + * Converts the websocket URI to the HTTPS one the document is served from. + * + *

NIP-11 publishes the document at the same host and path as the websocket endpoint, over + * HTTP rather than the websocket scheme. + */ + private URI httpUriOf(String relayUri) { + return URI.create(relayUri.replaceFirst("^ws", "http")); + } + + private Map structuredFrom(String relayUri, JsonNode document) { + Map structured = new LinkedHashMap<>(); + structured.put("relay", relayUri); + document.properties().forEach(entry -> structured.put(entry.getKey(), plainValue(entry.getValue()))); + return structured; + } + + private Object plainValue(JsonNode value) { + return value.isTextual() ? value.asText() : value.toString(); + } + + private String summarise(String relayUri, JsonNode document) { + StringBuilder summary = new StringBuilder(document.path("name").asText(relayUri)); + String description = document.path("description").asText(""); + if (!description.isBlank()) { + summary.append(": ").append(description); + } + JsonNode supportedNips = document.path("supported_nips"); + if (supportedNips.isArray() && !supportedNips.isEmpty()) { + summary.append(" Supports NIPs ").append(supportedNips); + } + return summary.toString(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/RemoveIdentityTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RemoveIdentityTool.java new file mode 100644 index 00000000..a87bc9c6 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RemoveIdentityTool.java @@ -0,0 +1,197 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.IdentityVault; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Destroys an identity: the only genuinely irreversible thing in this module. + * + *

An npub with no nsec is a dead account. No relay, backup or protocol can restore it, and + * every event ever signed with it is orphaned. So this tool is deliberately the hardest one to + * use by accident: it confirms in two steps, and it refuses outright when no backup has been + * taken unless the caller explicitly says the key is disposable. + * + *

That refusal is the important part. An agent acting on a vague instruction should not be + * able to destroy an account on a hunch, and requiring it to state that no backup is needed + * turns an ambiguous request into an explicit one. + */ +public final class RemoveIdentityTool implements NostrTool { + + private static final int TOKEN_BYTES = 16; + + private final Map aliasByToken = new ConcurrentHashMap<>(); + private final SecureRandom tokens = new SecureRandom(); + private final IdentityLifecycle lifecycle; + private final IdentityVault identityVault; + private final IdentityPolicy policy; + + /** + * @param lifecycle performs the removal + * @param identityVault the identities this server holds + * @param policy whether removal must be confirmed + */ + public RemoveIdentityTool( + @NonNull IdentityLifecycle lifecycle, + @NonNull IdentityVault identityVault, + @NonNull IdentityPolicy policy) { + this.lifecycle = lifecycle; + this.identityVault = identityVault; + this.policy = policy; + } + + @Override + public String name() { + return "nostr_remove_identity"; + } + + @Override + public String description() { + return "Permanently delete an identity's private key from this server. This cannot be undone" + + " and the account can never be used again. Export a backup first."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "alias", Map.of("type", "string", "description", "The identity to delete."), + "confirmationToken", + Map.of( + "type", + "string", + "description", + "Token from the first call. Omit to preview; supply it to delete."), + "acknowledgeNoBackup", + Map.of( + "type", + "boolean", + "description", + "Set only when the user has explicitly said this key is disposable and needs" + + " no backup.")), + "required", + List.of("alias")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return removeOrPreview(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } catch (nostr.mcp.identity.IdentityUnknownException e) { + return ToolFailure.IDENTITY_UNKNOWN.with(e.getMessage()); + } + } + + private CallToolResult removeOrPreview(ToolArguments arguments) { + Optional token = arguments.text("confirmationToken"); + if (token.isPresent()) { + return remove(confirmedAlias(token.get())); + } + String alias = arguments.requireText("alias"); + IdentitySummary identity = require(alias); + refuseWithoutBackupOrAcknowledgement(alias, arguments); + return policy.requiresConfirmation() ? preview(identity) : remove(alias); + } + + /** + * Refuses to destroy a key nobody has backed up. + * + *

The acknowledgement cannot be inferred, only stated. An agent that was told "clean up the + * test accounts" has no basis for deciding a key is disposable, so the tool makes it say so and + * puts that claim in the conversation where a user can contradict it. + */ + private void refuseWithoutBackupOrAcknowledgement(String alias, ToolArguments arguments) { + boolean acknowledged = + arguments.text("acknowledgeNoBackup").map(Boolean::parseBoolean).orElse(false); + if (!lifecycle.hasBackup(alias) && !acknowledged) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "No backup of '" + + alias + + "' has been exported in this session, and deleting it destroys the account" + + " permanently. Export one with nostr_export_identity_backup first, or set" + + " acknowledgeNoBackup only if the user has said this key is disposable."); + } + } + + private String confirmedAlias(String token) { + String alias = aliasByToken.remove(token); + if (alias == null) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "That confirmation token is not valid. It may already have been used, or the server may" + + " have restarted. Call this tool again without a token to start over."); + } + return alias; + } + + private IdentitySummary require(String alias) { + return identityVault + .find(alias) + .orElseThrow( + () -> + ToolFailure.IDENTITY_UNKNOWN.raise( + "No identity called '" + + alias + + "'. Available: " + + identityVault.list().stream().map(IdentitySummary::alias).toList())); + } + + private CallToolResult preview(IdentitySummary identity) { + String token = newToken(); + aliasByToken.put(token, identity.alias()); + return CallToolResult.builder() + .structuredContent( + Map.of( + "status", "awaiting-confirmation", + "alias", identity.alias(), + "publicKey", identity.publicKey(), + "npub", identity.npub(), + "hasBackup", lifecycle.hasBackup(identity.alias()), + "confirmationToken", token)) + .addTextContent( + "Nothing has been deleted yet. This would permanently destroy the private key for '" + + identity.alias() + + "' (" + + identity.npub() + + "), and the account could never be used again." + + (lifecycle.hasBackup(identity.alias()) + ? " A backup was exported earlier in this session." + : " No backup has been exported in this session.") + + " To go ahead, call this tool again with confirmationToken='" + + token + + "'.") + .build(); + } + + private CallToolResult remove(String alias) { + IdentitySummary identity = require(alias); + lifecycle.remove(alias); + return CallToolResult.builder() + .structuredContent(Map.of("alias", alias, "publicKey", identity.publicKey(), "removed", true)) + .addTextContent( + "Deleted identity '" + alias + "' (" + identity.npub() + "). This cannot be undone.") + .build(); + } + + private String newToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + tokens.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/RenameIdentityTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RenameIdentityTool.java new file mode 100644 index 00000000..e1f2fa4c --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/RenameIdentityTool.java @@ -0,0 +1,70 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentitySummary; + +import java.util.List; +import java.util.Map; + +/** + * Changes what an identity is called. + * + *

Freely allowed: an alias is this server's private label for a key, not part of the account, + * so renaming is invisible to the network and trivially undone by renaming back. + */ +public final class RenameIdentityTool implements NostrTool { + + private final IdentityLifecycle lifecycle; + + /** + * @param lifecycle performs the keystore change + */ + public RenameIdentityTool(@NonNull IdentityLifecycle lifecycle) { + this.lifecycle = lifecycle; + } + + @Override + public String name() { + return "nostr_rename_identity"; + } + + @Override + public String description() { + return "Rename one of this server's identities. The account itself is unchanged; only the" + + " local label changes."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "alias", Map.of("type", "string", "description", "The identity's current name."), + "newAlias", Map.of("type", "string", "description", "The name to use instead.")), + "required", + List.of("alias", "newAlias")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + ToolArguments arguments = new ToolArguments(request.arguments()); + String currentAlias = arguments.requireText("alias"); + IdentitySummary renamed = lifecycle.rename(currentAlias, arguments.requireText("newAlias")); + return CallToolResult.builder() + .structuredContent(Map.of("alias", renamed.alias(), "publicKey", renamed.publicKey())) + .addTextContent("Renamed '" + currentAlias + "' to '" + renamed.alias() + "'.") + .build(); + } catch (ToolException e) { + return e.asResult(); + } catch (nostr.mcp.identity.IdentityUnknownException e) { + return ToolFailure.IDENTITY_UNKNOWN.with(e.getMessage()); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/SendDirectMessageTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SendDirectMessageTool.java new file mode 100644 index 00000000..9facf61b --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SendDirectMessageTool.java @@ -0,0 +1,187 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.api.RecipientDeliveryOutcome; +import nostr.base.PublicKey; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.social.McpDirectMessageService; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sends a private message that only its recipients can read. + * + *

NIP-17 gift wrapping means the relay sees neither the correspondents nor the content, and + * delivery goes only to the relays each recipient nominated. Two consequences have to reach the + * agent rather than being smoothed over. + * + *

First, a recipient who has published no relay list cannot be sent to at all, so the message + * genuinely did not arrive and the user must be told who missed it. Second, every conversation + * includes a copy addressed to the sender, so a message to one person produces two outcomes. + * Reporting that as "1 of 2 delivered" would tell a user their message failed when it arrived + * perfectly well, so the sender's archival copy is reported separately from the recipients. + */ +public final class SendDirectMessageTool implements NostrTool { + + private final McpDirectMessageService directMessages; + private final IdentityVault identityVault; + + /** + * @param directMessages composes and delivers the message + * @param identityVault resolves which identity to send as + */ + public SendDirectMessageTool( + @NonNull McpDirectMessageService directMessages, @NonNull IdentityVault identityVault) { + this.directMessages = directMessages; + this.identityVault = identityVault; + } + + @Override + public String name() { + return "nostr_send_direct_message"; + } + + @Override + public String description() { + return "Send a private, encrypted direct message (NIP-17). Relays cannot see the sender," + + " the recipients or the content."; + } + + /** + * A bound server omits {@code identity}, since there is only one sender it could mean. + */ + @Override + public Map inputSchema() { + Map properties = new java.util.LinkedHashMap<>(); + properties.put( + "recipients", + Map.of( + "type", "array", + "description", "Who to send to, as hex public keys or npubs.", + "items", Map.of("type", "string"))); + properties.put("content", Map.of("type", "string", "description", "The message text.")); + if (!identityVault.binding().isBound()) { + properties.put( + "identity", Map.of("type", "string", "description", "Alias to send as. Omit for the default.")); + } + return Map.of("type", "object", "properties", properties, "required", List.of("recipients", "content")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return send(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult send(ToolArguments arguments) { + String alias = resolveIdentity(arguments); + List recipients = recipientsFrom(arguments); + List outcomes = + directMessages.send(alias, recipients, arguments.requireText("content")); + + String senderKey = directMessages.publicKeyOf(alias).toHexString(); + List toRecipients = + outcomes.stream().filter(outcome -> !outcome.recipient().equals(senderKey)).toList(); + List ownCopy = + outcomes.stream().filter(outcome -> outcome.recipient().equals(senderKey)).toList(); + + return CallToolResult.builder() + .structuredContent(structured(toRecipients, ownCopy)) + .addTextContent(summarise(toRecipients, ownCopy)) + .build(); + } + + private Map structured( + List toRecipients, List ownCopy) { + Map structured = new LinkedHashMap<>(); + structured.put("recipients", toRecipients.stream().map(SendDirectMessageTool::describe).toList()); + structured.put("delivered", toRecipients.stream().filter(RecipientDeliveryOutcome::isDelivered).count()); + structured.put("recipientCount", toRecipients.size()); + structured.put( + "ownArchivalCopy", + ownCopy.stream().map(SendDirectMessageTool::describe).findFirst().orElse(Map.of())); + return structured; + } + + private static Map describe(RecipientDeliveryOutcome outcome) { + Map described = new LinkedHashMap<>(); + described.put("recipient", outcome.recipient()); + described.put("status", outcome.status().name()); + described.put("reason", outcome.findReason().orElse("")); + return described; + } + + /** + * Reports the recipients as the answer, and the sender's own copy as advice. + * + *

A sender with no relay list of their own gets an UNREACHABLE archival copy while the real + * recipient is delivered. That is worth mentioning, because they will not see this message on + * their other devices, but it is not a delivery failure and must not read as one. + */ + private String summarise( + List toRecipients, List ownCopy) { + long delivered = toRecipients.stream().filter(RecipientDeliveryOutcome::isDelivered).count(); + StringBuilder summary = new StringBuilder(); + if (delivered == toRecipients.size()) { + summary.append("Delivered to ").append(delivered).append(toRecipients.size() == 1 ? " recipient." : " recipients."); + } else { + summary.append("Delivered to ").append(delivered).append(" of ").append(toRecipients.size()).append(" recipients."); + toRecipients.stream() + .filter(outcome -> !outcome.isDelivered()) + .forEach( + outcome -> + summary + .append(" ") + .append(outcome.recipient()) + .append(" could not be reached") + .append( + outcome.status() == RecipientDeliveryOutcome.Status.UNREACHABLE + ? " because they have published no relay list saying where to send" + + " private messages" + : "") + .append('.')); + } + ownCopy.stream() + .filter(outcome -> !outcome.isDelivered()) + .findFirst() + .ifPresent( + outcome -> + summary.append( + " Your own archival copy was not stored, so this message will not appear in" + + " your other clients; publish a kind-10050 relay list to fix that.")); + return summary.toString(); + } + + private List recipientsFrom(ToolArguments arguments) { + List recipients = arguments.texts("recipients"); + if (recipients.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise("Give at least one recipient"); + } + return recipients.stream() + .map(recipient -> NostrIdentifier.publicKey("recipients", recipient).asPublicKey()) + .toList(); + } + + private String resolveIdentity(ToolArguments arguments) { + return arguments + .text("identity") + .orElseGet( + () -> + identityVault + .defaultAlias() + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + "This server holds several identities and none is the default, so" + + " it cannot tell who should send. Name one in 'identity'."))); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/SetDefaultIdentityTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SetDefaultIdentityTool.java new file mode 100644 index 00000000..ae501906 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SetDefaultIdentityTool.java @@ -0,0 +1,66 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.identity.IdentityVault; + +import java.util.List; +import java.util.Map; + +/** + * Chooses which identity signs when a caller names none. + * + *

Freely allowed and worth having, because the alternative to a default is that every write + * carries an alias and an agent that forgets one is told the choice is ambiguous. Setting a + * default resolves that permanently, and it changes nothing on the network. + */ +public final class SetDefaultIdentityTool implements NostrTool { + + private final IdentityVault identityVault; + + /** + * @param identityVault the identities this server holds + */ + public SetDefaultIdentityTool(@NonNull IdentityVault identityVault) { + this.identityVault = identityVault; + } + + @Override + public String name() { + return "nostr_set_default_identity"; + } + + @Override + public String description() { + return "Choose which identity this server signs with when no identity is named."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of("alias", Map.of("type", "string", "description", "The identity to sign as by default.")), + "required", + List.of("alias")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + String alias = new ToolArguments(request.arguments()).requireText("alias"); + identityVault.setDefault(alias); + return CallToolResult.builder() + .structuredContent(Map.of("default", alias)) + .addTextContent("This server will now sign as '" + alias + "' unless told otherwise.") + .build(); + } catch (ToolException e) { + return e.asResult(); + } catch (nostr.mcp.identity.IdentityUnknownException e) { + return ToolFailure.IDENTITY_UNKNOWN.with(e.getMessage()); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/SubscribeTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SubscribeTool.java new file mode 100644 index 00000000..4b7d891f --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/SubscribeTool.java @@ -0,0 +1,81 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.EventFilterArguments; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.subscription.LiveSubscription; +import nostr.mcp.subscription.SubscriptionRegistry; + +import java.time.Clock; +import java.util.List; +import java.util.Map; + +/** + * Opens a standing watch for events that have not happened yet. + * + *

A query answers what a relay already holds; this answers "tell me when someone mentions + * me", which no single call can. The subscription outlives the call and the agent collects from + * it later. + * + *

It deliberately does not wait for the backlog. The SDK's subscribe returns before any + * stored event arrives, and blocking until it drained would stall on any relay that never + * answers, which is exactly what the SDK's own timeout exists to avoid. So the result says + * plainly that history is still replaying. + */ +public final class SubscribeTool implements NostrTool { + + private final SubscriptionRegistry subscriptions; + private final Clock clock; + + /** + * @param subscriptions where open subscriptions live + * @param clock what "now" means when resolving relative times + */ + public SubscribeTool(@NonNull SubscriptionRegistry subscriptions, @NonNull Clock clock) { + this.subscriptions = subscriptions; + this.clock = clock; + } + + @Override + public String name() { + return "nostr_subscribe"; + } + + @Override + public String description() { + return "Watch for events matching a filter as they arrive. Returns a subscription id; read" + + " from it with nostr_read_subscription and close it with nostr_unsubscribe."; + } + + @Override + public Map inputSchema() { + return EventFilterArguments.schema(); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + LiveSubscription opened = + subscriptions.open( + EventFilterArguments.toFilter(new ToolArguments(request.arguments()), clock)); + return CallToolResult.builder() + .structuredContent( + Map.of( + "subscriptionId", opened.id(), + "backlogDrained", opened.backlogDrained(), + "relays", List.copyOf(opened.subscribedRelays()))) + .addTextContent( + "Watching as '" + + opened.id() + + "' across " + + opened.subscribedRelays().size() + + " relay(s). The relays are still replaying their stored events, so read it" + + " with nostr_read_subscription in a moment rather than immediately.") + .build(); + } catch (ToolException e) { + return e.asResult(); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolException.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolException.java new file mode 100644 index 00000000..8e251241 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolException.java @@ -0,0 +1,51 @@ +package nostr.mcp.tool; + +import lombok.Getter; +import lombok.NonNull; + +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +/** + * A failure raised where it is detected and reported where the agent can see it. + * + *

Argument decoding and relay access happen several calls below the tool method, so returning + * a {@link ToolFailure} from there would mean threading a result type through every intermediate + * signature. Throwing keeps those methods returning the value they are about, and + * {@link #asResult()} turns the failure back into the agent-facing form at the boundary. + * + *

Unchecked, because a caller that cannot decode an identifier has nothing useful to do with + * a checked exception except rethrow it. + */ +@Getter +public final class ToolException extends RuntimeException { + + private final transient ToolFailure failure; + + /** + * @param failure the stable code the agent will see + * @param detail what went wrong, in terms the agent can act on + */ + public ToolException(@NonNull ToolFailure failure, @NonNull String detail) { + super(detail); + this.failure = failure; + } + + /** + * @param failure the stable code the agent will see + * @param detail what went wrong + * @param cause the underlying failure, kept for the server's logs but never shown to the agent + */ + public ToolException(@NonNull ToolFailure failure, @NonNull String detail, Throwable cause) { + super(detail, cause); + this.failure = failure; + } + + /** + * Render this failure as the agent sees it. + * + * @return an error result carrying the code and the message + */ + public CallToolResult asResult() { + return failure.with(getMessage()); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java new file mode 100644 index 00000000..faa6418d --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java @@ -0,0 +1,53 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; + +/** + * The stable codes a tool reports instead of throwing. + * + *

An agent cannot act on a stack trace, but it can act on a code: {@code RELAY_UNREACHABLE} + * invites a retry, {@code INVALID_ARGUMENT} invites a correction, {@code WRITE_FORBIDDEN} says + * to stop asking. Keeping the vocabulary in one enum means a new tool picks from the set rather + * than inventing a phrasing the agent has never seen. + */ +public enum ToolFailure { + /** An argument was missing, malformed, or outside the allowed range. */ + INVALID_ARGUMENT, + /** No relay could be reached to answer the request. */ + RELAY_UNREACHABLE, + /** Every relay refused the event, with their reasons in the message. */ + RELAY_REJECTED, + /** The relay accepted the request but did not answer in time. */ + TIMEOUT, + /** The named identity is not in the keystore. */ + IDENTITY_UNKNOWN, + /** Several identities exist and none is the default, so signing would be a guess. */ + IDENTITY_AMBIGUOUS, + /** The configured policy does not permit this write. */ + WRITE_FORBIDDEN, + /** The named subscription does not exist or has been reaped. */ + SUBSCRIPTION_UNKNOWN, + /** The server already holds as many subscriptions as it allows. */ + SUBSCRIPTION_LIMIT_REACHED; + + /** + * Raise this failure from wherever it is detected. + * + * @param detail what went wrong, in terms the agent can act on + * @return an exception the tool boundary turns back into a result + */ + public ToolException raise(@NonNull String detail) { + return new ToolException(this, detail); + } + + /** + * Report this failure to the agent. + * + * @param detail what went wrong, in terms the agent can act on + * @return an error result carrying the code and the detail + */ + public CallToolResult with(@NonNull String detail) { + return CallToolResult.builder().isError(true).addTextContent(name() + ": " + detail).build(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java new file mode 100644 index 00000000..f82aa79a --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java @@ -0,0 +1,143 @@ +package nostr.mcp.tool; + +import lombok.NonNull; +import nostr.client.relay.RelayPool; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.directory.Nip05Resolver; +import nostr.mcp.directory.WellKnownJson; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.social.McpDirectMessageService; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; + +import java.time.Clock; +import java.util.List; + +/** + * Builds the set of tools a server exposes, given how that server is configured. + * + *

The safety model is "a tool an agent cannot see is a tool it cannot misuse", which only + * works if there is exactly one place that decides what is visible. This is that place, and it is + * a separate type from the application so a test can assert the real surface instead of a + * stand-in: a golden file over stub tools proves nothing about what a host actually receives. + */ +public final class ToolSurface { + + private ToolSurface() {} + + /** + * Assemble the tools for a server. + * + *

Single-identity mode omits the keystore lifecycle tools entirely. A bound server operates + * a key; it does not administer the keystore, and key administration belongs to the human who + * set the servers up rather than to any agent. + * + * @param relayDirectory the configured relays + * @param relayPool the live connections + * @param identityVault the keys this server holds + * @param queryLimits the bounds every read stays inside + * @param clock what "now" means when resolving relative times + * @param writeGuard the single point every write passes through + * @param writePolicy whether write tools appear at all + * @param identityLifecycle performs keystore changes, or {@code null} when the backend cannot + * @param identityPolicy whether keystore-mutating tools appear at all + * @param subscriptions where open subscriptions live + * @param directMessages sends and reads private messages + * @return the registry a host will see + */ + public static NostrToolRegistry forServer( + @NonNull RelayDirectory relayDirectory, + @NonNull RelayPool relayPool, + @NonNull IdentityVault identityVault, + @NonNull QueryLimits queryLimits, + @NonNull Clock clock, + @NonNull WriteGuard writeGuard, + @NonNull WritePolicy writePolicy, + IdentityLifecycle identityLifecycle, + @NonNull IdentityPolicy identityPolicy, + @NonNull SubscriptionRegistry subscriptions, + @NonNull McpDirectMessageService directMessages) { + EventQuery eventQuery = new EventQuery(relayPool); + WellKnownJson wellKnownJson = new WellKnownJson(); + NostrToolRegistry registry = + new NostrToolRegistry() + .register(new ListRelaysTool(relayDirectory, relayPool)) + .register(new ListIdentitiesTool(identityVault)) + .register(new QueryEventsTool(eventQuery, queryLimits, clock)) + .register(new GetProfileTool(eventQuery, new Nip05Resolver(wellKnownJson), queryLimits)) + .register(new RelayInfoTool(relayDirectory, wellKnownJson)) + .register(new SubscribeTool(subscriptions, clock)) + .register(new ReadSubscriptionTool(subscriptions)) + .register(new ListSubscriptionsTool(subscriptions)) + .register(new UnsubscribeTool(subscriptions)) + .register(new FetchThreadTool(eventQuery, queryLimits)) + .register(new GetContactsTool(eventQuery, identityVault, queryLimits)) + .register( + new ReadDirectMessagesTool( + directMessages, identityVault, eventQuery, queryLimits, clock)); + if (writePolicy.allowsWriteTools()) { + writeTools(writeGuard).forEach(registry::register); + registry.register(new SendDirectMessageTool(directMessages, identityVault)); + } + if (registersAdministration(identityVault, identityPolicy, identityLifecycle)) { + administrationTools(identityLifecycle, identityVault, identityPolicy).forEach(registry::register); + } + return registry; + } + + /** + * Whether this server administers its keystore at all. + * + *

Three separate reasons to say no, and each is a different question. A bound server + * operates one key and does not administer a keystore. A denied policy forbids mutation. And a + * backend that cannot be written to, such as a keychain on a host without one, has nothing to + * offer, so registering tools that would always fail would be a worse answer than not offering + * them. + */ + private static boolean registersAdministration( + IdentityVault identityVault, IdentityPolicy identityPolicy, IdentityLifecycle lifecycle) { + return lifecycle != null + && identityPolicy.allowsMutation() + && !identityVault.binding().isBound(); + } + + /** + * The tools that mutate the keystore. + * + *

Kept together so the whole dangerous half of the surface appears in one list: what an + * agent can do to a user's keys should be readable at a glance rather than gathered from six + * registration calls. + */ + private static List administrationTools( + IdentityLifecycle lifecycle, IdentityVault identityVault, IdentityPolicy identityPolicy) { + return List.of( + new CreateIdentityTool(lifecycle), + new ImportIdentityTool(lifecycle), + new RenameIdentityTool(lifecycle), + new SetDefaultIdentityTool(identityVault), + new ExportIdentityBackupTool(lifecycle), + new RemoveIdentityTool(lifecycle, identityVault, identityPolicy)); + } + + /** + * The tools that publish, registered only when the policy permits writing at all. + * + *

Under {@code write-policy: deny} they are absent rather than refusing, because a tool an + * agent cannot see is a tool it cannot be talked into using. A refusal it can see is an + * invitation to try a different phrasing. + * + * @param writeGuard the guard each write tool publishes through + * @return the publishing tools + */ + private static List writeTools(WriteGuard writeGuard) { + return List.of( + new PublishNoteTool(writeGuard), + new PublishEventTool(writeGuard), + new UpdateProfileTool(writeGuard)); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/UnsubscribeTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/UnsubscribeTool.java new file mode 100644 index 00000000..68f25e40 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/UnsubscribeTool.java @@ -0,0 +1,67 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.subscription.LiveSubscription; +import nostr.mcp.subscription.SubscriptionRegistry; + +import java.util.List; +import java.util.Map; + +/** + * Stops watching, and frees what the subscription was holding. + * + *

Idle subscriptions are reaped eventually, but an agent that has finished with one should be + * able to say so: the cap on open subscriptions is shared, so leaving them to time out spends a + * limited resource for no reason. + */ +public final class UnsubscribeTool implements NostrTool { + + private final SubscriptionRegistry subscriptions; + + /** + * @param subscriptions where open subscriptions live + */ + public UnsubscribeTool(@NonNull SubscriptionRegistry subscriptions) { + this.subscriptions = subscriptions; + } + + @Override + public String name() { + return "nostr_unsubscribe"; + } + + @Override + public String description() { + return "Stop watching and discard anything the subscription had buffered."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "subscriptionId", + Map.of("type", "string", "description", "The subscription to close.")), + "required", + List.of("subscriptionId")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + String id = new ToolArguments(request.arguments()).requireText("subscriptionId"); + LiveSubscription closed = subscriptions.close(id); + return CallToolResult.builder() + .structuredContent(Map.of("subscriptionId", closed.id(), "closed", true)) + .addTextContent("Stopped watching '" + closed.id() + "'.") + .build(); + } catch (ToolException e) { + return e.asResult(); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/UpdateProfileTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/UpdateProfileTool.java new file mode 100644 index 00000000..8a0eaae3 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/UpdateProfileTool.java @@ -0,0 +1,101 @@ +package nostr.mcp.tool; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.write.WriteGuard; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Publishes profile metadata. + * + *

Kind 0 is replaceable, so publishing one replaces the whole profile rather than + * amending it: a call that sets only a name erases the existing picture and description. That is + * a trap for an agent thinking in terms of updating a field, so the tool says so in its + * description and the preview shows exactly what the profile will become. + */ +public final class UpdateProfileTool extends PublishingTool { + + private static final int PROFILE_KIND = 0; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final List PROFILE_FIELDS = + List.of("name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"); + + /** + * @param writeGuard the point every write passes through + */ + public UpdateProfileTool(WriteGuard writeGuard) { + super(writeGuard); + } + + @Override + public String name() { + return "nostr_update_profile"; + } + + @Override + public String description() { + return "Replace your public profile metadata. This replaces the whole profile rather than" + + " editing it, so include every field you want to keep. Read the current profile with" + + " nostr_get_profile first."; + } + + @Override + protected Map writeSpecificProperties() { + Map properties = new LinkedHashMap<>(); + PROFILE_FIELDS.forEach( + field -> properties.put(field, Map.of("type", "string", "description", describe(field)))); + return properties; + } + + @Override + protected List writeSpecificRequired() { + return List.of(); + } + + @Override + protected GenericEvent buildEvent(ToolArguments arguments) { + Map profile = new LinkedHashMap<>(); + PROFILE_FIELDS.forEach(field -> arguments.text(field).ifPresent(value -> profile.put(field, value))); + if (profile.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "Give at least one profile field. Publishing an empty profile would erase the" + + " existing one."); + } + return GenericEvent.builder() + .kind(PROFILE_KIND) + .content(asJson(profile)) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + } + + private String asJson(Map profile) { + try { + return MAPPER.writeValueAsString(profile); + } catch (JsonProcessingException e) { + throw ToolFailure.INVALID_ARGUMENT.raise("The profile could not be encoded: " + e.getMessage()); + } + } + + @Override + protected String describeForPreview(GenericEvent event) { + return "Your profile would become exactly:\n" + event.getContent(); + } + + private String describe(String field) { + return switch (field) { + case "name" -> "Short username."; + case "display_name" -> "Full display name."; + case "about" -> "A short biography."; + case "picture" -> "URL of an avatar image."; + case "banner" -> "URL of a banner image."; + case "website" -> "URL of a personal site."; + case "nip05" -> "A NIP-05 address such as alice@example.com."; + default -> "A lightning address for receiving zaps."; + }; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/transport/BindAddress.java b/nostr-java-mcp/src/main/java/nostr/mcp/transport/BindAddress.java new file mode 100644 index 00000000..2f4694a2 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/transport/BindAddress.java @@ -0,0 +1,78 @@ +package nostr.mcp.transport; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Where the HTTP transport listens, and whether that is safe. + * + *

The transport has no authentication of its own, so anything that can reach it can publish + * as every identity the server holds and read every message it can decrypt. Binding to loopback + * is what makes that acceptable by default, and a deployment that binds wider has made a + * decision it should be told about rather than one it can drift into. + */ +@Slf4j +public final class BindAddress { + + private static final String LOOPBACK = "127.0.0.1"; + + private final String host; + + private BindAddress(String host) { + this.host = host; + } + + /** + * Read the configured address, defaulting to loopback. + * + * @param configured the value from configuration, which may be null + * @return the address to bind + */ + public static BindAddress fromConfiguredValue(String configured) { + return new BindAddress(configured == null || configured.isBlank() ? LOOPBACK : configured.trim()); + } + + /** + * The host to bind. + * + * @return the address + */ + public String host() { + return host; + } + + /** + * Whether this address is reachable only from the machine itself. + * + * @return true when it is a loopback address + */ + public boolean isLoopback() { + try { + return InetAddress.getByName(host).isLoopbackAddress(); + } catch (UnknownHostException unresolvable) { + return false; + } + } + + /** + * Warns when the server is about to be reachable from the network. + * + *

Noisy rather than silent, on the same principle as the unprotected keystore backend: the + * weaker choice should announce itself, because the person who made it may not be the person + * reading the logs. + */ + public void warnIfReachableFromTheNetwork() { + if (!isLoopback()) { + log.warn( + "The MCP HTTP transport is bound to {}, which is reachable beyond this machine, and it" + + " has no authentication of its own. Anything that can reach it can publish as" + + " every identity this server holds. Put a reverse proxy with real credentials in" + + " front of it, or bind {} instead.", + host, + LOOPBACK); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/transport/HttpMcpServer.java b/nostr-java-mcp/src/main/java/nostr/mcp/transport/HttpMcpServer.java new file mode 100644 index 00000000..79206d62 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/transport/HttpMcpServer.java @@ -0,0 +1,146 @@ +package nostr.mcp.transport; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.subscription.SubscriptionResources; +import nostr.mcp.tool.NostrToolRegistry; +import org.apache.catalina.startup.Tomcat; + +import java.io.File; +import java.io.IOException; + +/** + * Serves the MCP tools over streamable HTTP. + * + *

The alternative to stdio, for a hosted deployment where the host does not launch the + * process itself. It runs an embedded servlet container rather than requiring one, because a + * server an operator has to deploy into a container is a server most people will not run. + * + *

This transport has no authentication. It binds to loopback by default and + * says so loudly when it does not, but anything that can reach it can act as every identity the + * server holds. A deployment exposing it beyond the machine needs a reverse proxy with real + * credentials in front. + */ +@Slf4j +public final class HttpMcpServer implements AutoCloseable { + + private static final String SERVER_NAME = "nostr-java-mcp"; + private static final String MCP_ENDPOINT = "/mcp"; + private static final String CONTEXT_PATH = ""; + private static final String SERVLET_NAME = "mcp"; + + private final McpSyncServer server; + private final Tomcat tomcat; + + /** + * Start serving. + * + * @param toolRegistry the tools an agent will see + * @param version the server version reported to the host + * @param subscriptions open subscriptions to expose as resources, or {@code null} for none + * @param bindAddress where to listen + * @param port which port to listen on + * @throws IOException when the port cannot be bound + */ + public HttpMcpServer( + @NonNull NostrToolRegistry toolRegistry, + @NonNull String version, + SubscriptionRegistry subscriptions, + @NonNull BindAddress bindAddress, + int port) + throws IOException { + bindAddress.warnIfReachableFromTheNetwork(); + + HttpServletStreamableServerTransportProvider transport = + HttpServletStreamableServerTransportProvider.builder() + .jsonMapper(McpJsonDefaults.getMapper()) + .mcpEndpoint(MCP_ENDPOINT) + .build(); + + var builder = + McpServer.sync(transport) + .serverInfo(SERVER_NAME, version) + .capabilities( + ServerCapabilities.builder() + .tools(true) + .resources(subscriptions != null, subscriptions != null) + .build()) + .tools(toolRegistry.toSpecifications()); + if (subscriptions != null) { + builder = builder.resources(SubscriptionResources.specification(subscriptions)); + } + this.server = builder.build(); + this.tomcat = start(transport, bindAddress, port); + + log.info( + "MCP HTTP transport listening on http://{}:{}{}", + bindAddress.host(), + tomcat.getConnector().getLocalPort(), + MCP_ENDPOINT); + } + + /** + * The port actually bound, which differs from the requested one when zero was asked for. + * + * @return the listening port + */ + public int port() { + return tomcat.getConnector().getLocalPort(); + } + + /** + * The URL an MCP host connects to. + * + * @param bindAddress the address this server was bound to + * @return the endpoint URL + */ + public String endpointUrl(@NonNull BindAddress bindAddress) { + return "http://" + bindAddress.host() + ":" + port() + MCP_ENDPOINT; + } + + private Tomcat start( + HttpServletStreamableServerTransportProvider transport, BindAddress bindAddress, int port) + throws IOException { + Tomcat embedded = new Tomcat(); + embedded.setBaseDir(temporaryDirectory()); + embedded.setPort(port); + embedded.getConnector().setProperty("address", bindAddress.host()); + + var context = embedded.addContext(CONTEXT_PATH, temporaryDirectory()); + Tomcat.addServlet(context, SERVLET_NAME, transport).setAsyncSupported(true); + context.addServletMappingDecoded(MCP_ENDPOINT, SERVLET_NAME); + + try { + embedded.start(); + } catch (Exception e) { + throw new IOException("Could not start the MCP HTTP transport on port " + port, e); + } + return embedded; + } + + private String temporaryDirectory() throws IOException { + File directory = File.createTempFile("nostr-mcp-http", ""); + if (!directory.delete() || !directory.mkdirs()) { + throw new IOException("Could not create a working directory for the HTTP transport"); + } + directory.deleteOnExit(); + return directory.getAbsolutePath(); + } + + @Override + public void close() { + server.close(); + try { + tomcat.stop(); + tomcat.destroy(); + } catch (Exception e) { + log.warn("Could not stop the MCP HTTP transport cleanly: {}", e.getMessage()); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/write/PendingWrite.java b/nostr-java-mcp/src/main/java/nostr/mcp/write/PendingWrite.java new file mode 100644 index 00000000..273ab7ce --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/write/PendingWrite.java @@ -0,0 +1,16 @@ +package nostr.mcp.write; + +import nostr.event.impl.GenericEvent; + +/** + * A signed event waiting for the agent to confirm it. + * + *

The event is signed before it is previewed, so what the agent confirms is exactly what will + * be published: signing afterwards would let the content drift between the preview and the send, + * which is precisely the substitution confirmation exists to prevent. + * + * @param token the opaque handle the agent returns to publish this event + * @param event the signed event, ready to send + * @param identityAlias who it will be published as + */ +public record PendingWrite(String token, GenericEvent event, String identityAlias) {} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/write/RateLimit.java b/nostr-java-mcp/src/main/java/nostr/mcp/write/RateLimit.java new file mode 100644 index 00000000..94a65599 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/write/RateLimit.java @@ -0,0 +1,70 @@ +package nostr.mcp.write; + +import lombok.NonNull; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; + +/** + * Caps how often a subject may write. + * + *

An agent in a loop is the ordinary failure, not the exceptional one: a model that + * misreads its own output can publish the same note repeatedly, and on a public medium each + * repetition is permanent. The limit is per subject, so one identity exhausting its budget + * cannot silence another. + * + *

A sliding window rather than a fixed one, because a fixed window lets an agent spend a + * whole budget at the end of one period and another at the start of the next, producing a burst + * of twice the intended size at exactly the moment a runaway loop is most likely. + */ +public final class RateLimit { + + private final Map> timestampsBySubject = new HashMap<>(); + private final int maxWrites; + private final Duration window; + private final Clock clock; + + /** + * @param maxWrites the most writes allowed in the window + * @param window how far back the limit looks + * @param clock the source of time, so tests need not sleep + */ + public RateLimit(int maxWrites, @NonNull Duration window, @NonNull Clock clock) { + this.maxWrites = maxWrites; + this.window = window; + this.clock = clock; + } + + /** + * Record a write, if the subject has budget left. + * + * @param subject who or what is being limited, such as an identity alias or a relay + * @return true when the write may proceed + */ + public synchronized boolean tryAcquire(@NonNull String subject) { + long now = clock.millis(); + Deque timestamps = timestampsBySubject.computeIfAbsent(subject, key -> new ArrayDeque<>()); + long windowStart = now - window.toMillis(); + while (!timestamps.isEmpty() && timestamps.peekFirst() <= windowStart) { + timestamps.pollFirst(); + } + if (timestamps.size() >= maxWrites) { + return false; + } + timestamps.addLast(now); + return true; + } + + /** + * Describe the limit, for an error an agent can act on. + * + * @return the limit in words, such as "10 writes per 1m" + */ + public String describe() { + return maxWrites + " writes per " + window.toString().substring(2).toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java b/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java new file mode 100644 index 00000000..e93c9f3b --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java @@ -0,0 +1,249 @@ +package nostr.mcp.write; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.client.relay.NoRelayAcceptedException; +import nostr.client.relay.PublishResult; +import nostr.client.relay.RelayPool; +import nostr.event.impl.GenericEvent; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.tool.ToolFailure; + +import java.security.SecureRandom; +import java.time.Clock; +import java.util.HexFormat; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The one place a write can happen, and the conditions it must satisfy first. + * + *

Every publishing tool goes through here rather than reaching the relay pool itself, so the + * policy, the rate limit and the audit log are properties of the server rather than habits each + * tool has to remember. A new write tool inherits all three by construction. + * + *

Under {@link WritePolicy#CONFIRM} a write is a two-step conversation: the first call signs + * the event, holds it, and hands back a preview with a token; the second call presents the token + * and the held event is sent. This turns a hallucinated post into a no-op, because an agent that + * invented the intention will not follow through with the token it was given for it. + */ +@Slf4j +public final class WriteGuard { + + private static final int TOKEN_BYTES = 16; + + private final Map pendingByToken = new ConcurrentHashMap<>(); + private final SecureRandom tokens = new SecureRandom(); + private final RelayPool relayPool; + private final IdentityVault identityVault; + private final WritePolicy policy; + private final RateLimit rateLimit; + + /** + * @param relayPool where accepted writes go + * @param identityVault signs the event, without releasing the key + * @param policy how much freedom the agent has + * @param rateLimit the cap on how often it may write + */ + public WriteGuard( + @NonNull RelayPool relayPool, + @NonNull IdentityVault identityVault, + @NonNull WritePolicy policy, + @NonNull RateLimit rateLimit) { + this.relayPool = relayPool; + this.identityVault = identityVault; + this.policy = policy; + this.rateLimit = rateLimit; + } + + /** + * Prepare an event for publishing, signing it as the chosen identity. + * + * @param event the unsigned event + * @param requestedAlias the identity to sign as, or empty to use the default + * @return the signed event held under a token + * @throws nostr.mcp.tool.ToolException when writing is denied, the identity is unknown or + * ambiguous, or the rate limit is exhausted + */ + public PendingWrite prepare(@NonNull GenericEvent event, Optional requestedAlias) { + refuseIfDenied(); + String alias = resolveIdentity(requestedAlias); + enforceRateLimit(alias); + GenericEvent signed = sign(event, alias); + PendingWrite pending = + new PendingWrite(newToken(), signed, alias); + if (policy.requiresConfirmation()) { + pendingByToken.put(pending.token(), pending); + } + return pending; + } + + /** + * Publish an event the agent has confirmed. + * + * @param token the handle from the preview + * @return the per-relay outcome + * @throws nostr.mcp.tool.ToolException when the token is unknown or every relay refused + */ + public PublishResult publishConfirmed(@NonNull String token) { + PendingWrite pending = pendingByToken.remove(token); + if (pending == null) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "That confirmation token is not valid. It may already have been used, or the server may" + + " have restarted. Call the tool again without a token to get a fresh preview."); + } + return send(pending); + } + + /** + * Publish immediately, for a policy that does not require confirmation. + * + * @param pending the prepared write + * @return the per-relay outcome + * @throws nostr.mcp.tool.ToolException when every relay refused + */ + public PublishResult publishDirectly(@NonNull PendingWrite pending) { + return send(pending); + } + + /** + * Whether this process operates exactly one identity. + * + *

Signing tools ask so they can leave the {@code identity} argument out of their schema + * entirely. Offering an argument with one legal value invites a model to pass the wrong thing + * and turns an impossible mistake back into a possible one. + * + * @return true when the server is bound to a single identity + */ + public boolean bindsOneIdentity() { + return identityVault.binding().isBound(); + } + + /** + * Whether a write must be confirmed before it is sent. + * + * @return true under the confirming policy + */ + public boolean requiresConfirmation() { + return policy.requiresConfirmation(); + } + + /** + * Sends the event and records what happened. + * + *

Partial success is success. The SDK reports each relay separately, and an event accepted + * anywhere is on the network; calling that a failure would invite the agent to retry a write + * that already landed, which on a permanent medium is worse than the original problem. + */ + private PublishResult send(PendingWrite pending) { + try { + PublishResult result = relayPool.publish(pending.event()); + logWrite(pending, result); + return result; + } catch (java.io.IOException e) { + if (!(e instanceof NoRelayAcceptedException rejection)) { + throw ToolFailure.RELAY_UNREACHABLE.raise("Could not reach any relay: " + e.getMessage()); + } + log.warn( + "Write rejected: event {} kind {} as {} reached no relay", + pending.event().getId(), + pending.event().getKind(), + identityVault.publicKeyOf(pending.identityAlias()).toHexString()); + throw ToolFailure.RELAY_REJECTED.raise(describeRejection(rejection)); + } + } + + /** + * Records every write with what was published, by whom, and where it went. + * + *

The audit trail is the only durable record of what an agent did on the user's behalf, and + * it outlives the conversation that caused it. + */ + private void logWrite(PendingWrite pending, PublishResult result) { + log.info( + "Write accepted: event {} kind {} as {} to {}", + result.getEventId(), + pending.event().getKind(), + identityVault.publicKeyOf(pending.identityAlias()).toHexString(), + result.getAcceptingRelays()); + } + + private String describeRejection(NoRelayAcceptedException rejection) { + PublishResult result = rejection.getPublishResult(); + if (result == null) { + return "No relay accepted the event: " + rejection.getMessage(); + } + StringBuilder detail = new StringBuilder("No relay accepted the event."); + result + .getFailures() + .forEach( + failure -> + detail + .append(' ') + .append(failure.relayUri()) + .append(": ") + .append(failure.findReason().orElse(failure.status().name())) + .append('.')); + return detail.toString(); + } + + private void refuseIfDenied() { + if (!policy.allowsWriteTools()) { + throw ToolFailure.WRITE_FORBIDDEN.raise( + "This server is configured read-only (write-policy: deny), so it cannot publish."); + } + } + + /** + * Chooses the identity to sign as, refusing to guess. + * + *

Posting as the wrong account is public and irreversible, so where several identities exist + * and none is the default, the tool asks rather than picking one. + */ + private String resolveIdentity(Optional requestedAlias) { + if (requestedAlias.isPresent()) { + String alias = requestedAlias.get(); + if (identityVault.find(alias).isEmpty()) { + throw ToolFailure.IDENTITY_UNKNOWN.raise( + "No identity called '" + + alias + + "'. Available: " + + identityVault.list().stream().map(summary -> summary.alias()).toList()); + } + return alias; + } + return identityVault + .defaultAlias() + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + identityVault.isEmpty() + ? "This server holds no identity to sign with. Create one with the" + + " command line: java -jar nostr-java-mcp.jar keygen " + : "This server holds several identities and none is the default, so" + + " signing would be a guess. Name one in the 'identity' argument:" + + identityVault.list().stream().map(summary -> summary.alias()).toList())); + } + + private void enforceRateLimit(String alias) { + if (!rateLimit.tryAcquire(alias)) { + throw ToolFailure.WRITE_FORBIDDEN.raise( + "Identity '" + alias + "' has reached its write rate limit of " + rateLimit.describe() + + ". Wait before publishing again."); + } + } + + private GenericEvent sign(GenericEvent event, String alias) { + event.setPubKey(identityVault.publicKeyOf(alias)); + event.update(); + identityVault.signAs(alias, event); + return event; + } + + private String newToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + tokens.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/write/WritePolicy.java b/nostr-java-mcp/src/main/java/nostr/mcp/write/WritePolicy.java new file mode 100644 index 00000000..16041663 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/write/WritePolicy.java @@ -0,0 +1,61 @@ +package nostr.mcp.write; + +import java.util.Locale; + +/** + * How much freedom an agent has to publish. + * + *

Publishing to Nostr is public and irreversible: NIP-09 deletion is advisory, so a relay may + * keep an event forever whatever its author later asks. A hallucinated post is therefore not a + * recoverable mistake, and the deployment decides in advance how much trust the agent gets. + */ +public enum WritePolicy { + + /** Write tools are not registered, so a read-only server cannot be talked into posting. */ + DENY, + + /** Write tools preview first and publish only when the agent returns the token. */ + CONFIRM, + + /** Writes proceed directly, for automation whose output is already trusted. */ + ALLOW; + + /** + * Read the configured policy, defaulting to the cautious one. + * + *

An unrecognised value falls back to {@link #CONFIRM} rather than failing: a typo in a + * config file should not stop a server from starting, but neither should it quietly grant more + * freedom than the operator asked for. + * + * @param configured the value from configuration, which may be null + * @return the policy to enforce + */ + public static WritePolicy fromConfiguredValue(String configured) { + if (configured == null || configured.isBlank()) { + return CONFIRM; + } + try { + return valueOf(configured.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException unrecognised) { + return CONFIRM; + } + } + + /** + * Whether write tools appear on the surface at all. + * + * @return true unless writing is denied + */ + public boolean allowsWriteTools() { + return this != DENY; + } + + /** + * Whether a write needs a second call carrying a token. + * + * @return true when confirmation is required + */ + public boolean requiresConfirmation() { + return this == CONFIRM; + } +} diff --git a/nostr-java-mcp/src/main/resources/nostr-mcp-build.properties b/nostr-java-mcp/src/main/resources/nostr-mcp-build.properties new file mode 100644 index 00000000..1ad34e0c --- /dev/null +++ b/nostr-java-mcp/src/main/resources/nostr-mcp-build.properties @@ -0,0 +1,2 @@ +# Filtered at build time so the version an MCP host is told is the version that was built. +version=${project.version} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java new file mode 100644 index 00000000..40150bac --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java @@ -0,0 +1,206 @@ +package nostr.mcp; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.social.McpDirectMessageService; +import nostr.mcp.subscription.SubscriptionLimits; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.tool.NostrToolRegistry; +import nostr.mcp.tool.ToolSurface; +import nostr.mcp.transport.BindAddress; +import nostr.mcp.transport.HttpMcpServer; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Speaks real MCP over HTTP to a running server. + * + *

The transport is where protocol framing, session handling and the servlet container meet, + * none of which a unit test exercises. A server that compiles and registers its tools can still + * fail to complete a handshake over the wire, and only an actual client discovers that. + */ +class HttpTransportIT { + + private static final String ALIAS = "personal"; + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + // Verifies a host can connect over HTTP, complete the handshake, and discover the same tools + // it would over stdio. + @Test + void aHostCanConnectOverHttpAndDiscoverTheTools() throws Exception { + try (RelayPool pool = poolOf(); + IdentityVault vault = vaultOf(); + SubscriptionRegistry subscriptions = subscriptionsOf(pool); + HttpMcpServer server = serverOn(pool, vault, subscriptions)) { + + try (McpSyncClient client = connectTo(server)) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.contains("nostr_list_relays"), tools.toString()); + assertTrue(tools.contains("nostr_list_identities"), tools.toString()); + } + } + } + + // Verifies a tool call round-trips over HTTP, proving the transport carries results and not + // only the handshake. + @Test + void aToolCallRoundTripsOverHttp() throws Exception { + try (RelayPool pool = poolOf(); + IdentityVault vault = vaultOf(); + SubscriptionRegistry subscriptions = subscriptionsOf(pool); + HttpMcpServer server = serverOn(pool, vault, subscriptions)) { + + try (McpSyncClient client = connectTo(server)) { + client.initialize(); + + CallToolResult result = + client.callTool(new CallToolRequest("nostr_list_identities", Map.of())); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).contains(ALIAS), textOf(result)); + } + } + } + + // Verifies the server binds the loopback interface by default, which is the only thing + // protecting an unauthenticated transport from anything that can route to the host. + @Test + void theServerBindsLoopbackByDefault() throws Exception { + try (RelayPool pool = poolOf(); + IdentityVault vault = vaultOf(); + SubscriptionRegistry subscriptions = subscriptionsOf(pool); + HttpMcpServer server = serverOn(pool, vault, subscriptions)) { + + assertTrue(BindAddress.fromConfiguredValue(null).isLoopback()); + assertTrue(server.port() > 0, "the server did not bind a port"); + } + } + + // Verifies the write policy still governs the surface over HTTP, so the transport is a way in + // and not a way around the safety model. + @Test + void theWritePolicyStillAppliesOverHttp() throws Exception { + try (RelayPool pool = poolOf(); + IdentityVault vault = vaultOf(); + SubscriptionRegistry subscriptions = subscriptionsOf(pool); + HttpMcpServer server = + new HttpMcpServer( + surfaceOf(pool, vault, subscriptions, WritePolicy.DENY), + "test", + subscriptions, + BindAddress.fromConfiguredValue(null), + 0)) { + + try (McpSyncClient client = connectTo(server)) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.stream().noneMatch(name -> name.contains("publish")), tools.toString()); + } + } + } + + private McpSyncClient connectTo(HttpMcpServer server) { + return McpClient.sync( + HttpClientStreamableHttpTransport.builder( + "http://127.0.0.1:" + server.port()) + .endpoint("/mcp") + .build()) + .requestTimeout(TIMEOUT) + .build(); + } + + private HttpMcpServer serverOn( + RelayPool pool, IdentityVault vault, SubscriptionRegistry subscriptions) throws Exception { + return new HttpMcpServer( + surfaceOf(pool, vault, subscriptions, WritePolicy.CONFIRM), + "test", + subscriptions, + BindAddress.fromConfiguredValue(null), + 0); + } + + private NostrToolRegistry surfaceOf( + RelayPool pool, IdentityVault vault, SubscriptionRegistry subscriptions, WritePolicy policy) { + return ToolSurface.forServer( + new RelayDirectory(Map.of(RelayDirectory.READ, List.of("wss://relay.one"))), + pool, + vault, + QueryLimits.defaults(), + Clock.systemUTC(), + new WriteGuard( + pool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())), + policy, + null, + IdentityPolicy.fromConfiguredValue(null, policy), + subscriptions, + new McpDirectMessageService(vault, pool, Set.of())); + } + + private SubscriptionRegistry subscriptionsOf(RelayPool pool) { + return new SubscriptionRegistry( + pool, SubscriptionLimits.defaults(), Clock.systemUTC(), subscriptionId -> {}); + } + + private RelayPool poolOf() { + return new RelayPool(List.of("wss://relay.one"), relayUri -> FakeRelay.accepting(relayUri)); + } + + private IdentityVault vaultOf() { + byte[] key = + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(ALIAS, key); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/KeyAdminCliIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/KeyAdminCliIT.java new file mode 100644 index 00000000..e6e06385 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/KeyAdminCliIT.java @@ -0,0 +1,146 @@ +package nostr.mcp; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives the key-admin CLI as a person does, from a real command line. + * + *

The unit tests exercise the CLI against an in-memory store, which cannot show that the same + * jar serves both roles, that the encrypted keystore is actually written to disk, or that a + * server started afterwards finds the key. Those are the claims the ticket makes, so they are + * checked by running the real entry point in a separate JVM. + */ +class KeyAdminCliIT { + + private static final String PASSPHRASE_VARIABLE = "NOSTR_MCP_KEYSTORE_PASSPHRASE"; + private static final String PASSPHRASE = "test-passphrase"; + + @TempDir Path keystoreDirectory; + + // Verifies the same jar that serves MCP also creates a key, and that the key lands in the + // keystore file rather than only in a message. + @Test + void theSameEntryPointCreatesAKeyOnDisk() throws Exception { + CommandResult created = runCli("keygen", "personal"); + + assertEquals(0, created.exitCode(), created.output()); + assertTrue(created.output().contains("npub1"), created.output()); + assertTrue(Files.exists(keystorePath()), "the keystore file was not written"); + + CommandResult listed = runCli("list"); + assertTrue(listed.output().contains("personal"), listed.output()); + } + + // Verifies the created private key never reaches standard output, which is the whole reason + // key administration is a command rather than a tool. + @Test + void theCreatedPrivateKeyIsNeverPrinted() throws Exception { + CommandResult created = runCli("keygen", "personal"); + + assertFalse(created.output().contains("nsec1"), created.output()); + } + + // Verifies the keystore is created readable only by its owner, since the passphrase is the + // only other thing protecting it. + @Test + void theKeystoreIsReadableOnlyByItsOwner() throws Exception { + runCli("keygen", "personal"); + + assertEquals("rw-------", posixPermissions(keystorePath())); + } + + // Verifies removing an identity really removes it, so a later server cannot sign with it. + @Test + void aRemovedIdentityIsGoneFromTheKeystore() throws Exception { + runCli("keygen", "personal"); + + CommandResult removed = runCli("remove", "personal"); + assertEquals(0, removed.exitCode(), removed.output()); + + CommandResult listed = runCli("list"); + assertFalse(listed.output().contains("personal"), listed.output()); + } + + // Verifies a server bound to an identity that does not exist refuses to start and says how to + // create it, rather than starting and failing when an agent first tries to post. + @Test + void aServerBoundToAMissingIdentityRefusesToStart() throws Exception { + runCli("keygen", "personal"); + + CommandResult server = runServerBoundTo("typo"); + + assertNotEquals(0, server.exitCode(), server.output()); + assertTrue(server.output().contains("typo"), server.output()); + assertTrue(server.output().contains("keygen"), server.output()); + } + + private String posixPermissions(Path file) throws IOException { + return java.nio.file.attribute.PosixFilePermissions.toString( + Files.getPosixFilePermissions(file)); + } + + private Path keystorePath() { + return keystoreDirectory.resolve("keys.p12"); + } + + private CommandResult runCli(String... commands) throws Exception { + return run(processArguments(List.of(commands))); + } + + /** + * Starts the server bound to an alias and waits for it to fail. + * + *

A successful start would block forever by design, so this is only used for the failure + * path, with a timeout so a regression that lets it start is a test failure rather than a hang. + */ + private CommandResult runServerBoundTo(String alias) throws Exception { + return run(processArguments(List.of(), "-Dnostr.mcp.identity=" + alias)); + } + + private List processArguments(List commands, String... extraProperties) { + List arguments = new ArrayList<>(); + arguments.add("java"); + arguments.add("-Dnostr.mcp.keystore.type=encrypted-file"); + arguments.add("-Dnostr.mcp.keystore.path=" + keystorePath()); + arguments.add("-Dnostr.mcp.relays.read=ws://localhost:1"); + arguments.addAll(List.of(extraProperties)); + arguments.add("-cp"); + arguments.add(System.getProperty("java.class.path")); + arguments.add(NostrMcpApplication.class.getName()); + arguments.addAll(commands); + return arguments; + } + + private CommandResult run(List arguments) throws Exception { + Process process = withPassphrase(new ProcessBuilder(arguments).redirectErrorStream(true)).start(); + process.getOutputStream().close(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("The process did not exit:\n" + output); + } + return new CommandResult(process.exitValue(), output); + } + + private record CommandResult(int exitCode, String output) {} + + /** The encrypted keystore refuses to open without one, so every run supplies the same value. */ + private ProcessBuilder withPassphrase(ProcessBuilder builder) { + builder.environment().put(PASSPHRASE_VARIABLE, PASSPHRASE); + return builder; + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java new file mode 100644 index 00000000..5b10a069 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java @@ -0,0 +1,162 @@ +package nostr.mcp; + +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies configuration is read as documented, and that the documentation matches the code. */ +class McpConfigurationTest { + + private static final Path GUIDE = Path.of("../docs/howto/run-the-mcp-server.md"); + private static final Pattern SETTING_IN_CODE = + Pattern.compile("(?:setting|settingOr|commaSeparated|positiveIntOr|durationOr|relayList)\\(\"([a-z0-9.-]+)\""); + + private final List propertiesSet = new java.util.ArrayList<>(); + + @AfterEach + void clearProperties() { + propertiesSet.forEach(System::clearProperty); + } + + // Verifies every setting the code reads is documented, so the guide cannot quietly fall + // behind a new option that only its author knows about. + @Test + void everySettingTheCodeReadsIsDocumented() { + String guide = read(GUIDE); + + for (String setting : settingsReadByTheCode()) { + assertTrue( + guide.contains("`" + setting + "`"), + "nostr.mcp." + setting + " is read by McpConfiguration but absent from the guide"); + } + } + + // Verifies the guide states the HTTP transport is unauthenticated, since a deferral of + // authentication is only safe while the constraint replacing it is visible. + @Test + void theGuideWarnsThatTheHttpTransportIsUnauthenticated() { + String guide = read(GUIDE); + + assertTrue(guide.contains("no authentication"), "the guide does not say the transport is unauthenticated"); + assertTrue(guide.contains("reverse proxy"), "the guide does not say what to do instead"); + assertTrue(guide.contains("127.0.0.1"), "the guide does not state the default binding"); + } + + // Verifies the default transport is stdio, since that is what an MCP host launches. + @Test + void theDefaultTransportIsStdio() { + assertEquals("stdio", McpConfiguration.fromEnvironment().transport()); + assertTrue(McpConfiguration.fromEnvironment().bindAddress().isLoopback()); + } + + // Verifies the HTTP transport is selected by configuration alone, so no code change is needed + // to move a deployment between transports. + @Test + void theHttpTransportIsSelectedByConfiguration() { + set("nostr.mcp.transport", "http"); + + assertTrue(McpConfiguration.fromEnvironment().usesHttpTransport()); + } + + // Verifies a nonsensical limit falls back rather than disabling reads, since a typo should not + // make every query return nothing. + @Test + void aNonsensicalLimitFallsBack() { + set("nostr.mcp.limits.max-events-per-query", "0"); + + assertEquals(500, McpConfiguration.fromEnvironment().queryLimits().maxEventsPerQuery()); + } + + // Verifies durations are read in the forms an operator actually writes. + @Test + void durationsAreReadInTheFormsPeopleWrite() { + set("nostr.mcp.limits.query-timeout", "30s"); + assertEquals(Duration.ofSeconds(30), McpConfiguration.fromEnvironment().queryLimits().queryTimeout()); + + set("nostr.mcp.limits.subscription-idle-timeout", "2h"); + assertEquals( + Duration.ofHours(2), McpConfiguration.fromEnvironment().subscriptionLimits().idleTimeout()); + } + + // Verifies a read-only server cannot mutate the keystore however identity-policy is set, + // since a server that cannot post should not be able to destroy an account. + @Test + void aReadOnlyServerCannotMutateTheKeystore() { + set("nostr.mcp.write-policy", "deny"); + set("nostr.mcp.identity-policy", "allow"); + + assertEquals(WritePolicy.DENY, McpConfiguration.fromEnvironment().writePolicy()); + assertEquals(IdentityPolicy.DENY, McpConfiguration.fromEnvironment().identityPolicy()); + } + + // Verifies decrypting messages is off unless named, since it exposes private correspondence. + @Test + void decryptingMessagesIsOffUntilNamed() { + assertEquals(Set.of(), McpConfiguration.fromEnvironment().identitiesPermittedToDecrypt()); + + set("nostr.mcp.dm.decrypt-for", "personal, work"); + assertEquals( + Set.of("personal", "work"), McpConfiguration.fromEnvironment().identitiesPermittedToDecrypt()); + } + + // Verifies every setting is reachable from the environment, since a container configures the + // server that way and a shell cannot set a variable whose name contains a hyphen. + @Test + void everySettingCanBeSetFromTheEnvironment() { + for (String setting : settingsReadByTheCode()) { + String variable = McpConfiguration.environmentVariableFor(setting); + + assertTrue( + variable.matches("[A-Z0-9_]+"), + setting + " maps to '" + variable + "', which no shell can set"); + } + } + + // Verifies hyphenated settings translate to usable variable names, which is the specific case + // that made write-policy and bind-address unconfigurable from a container. + @Test + void hyphenatedSettingsBecomeUnderscoredVariables() { + assertEquals("NOSTR_MCP_BIND_ADDRESS", McpConfiguration.environmentVariableFor("bind-address")); + assertEquals("NOSTR_MCP_WRITE_POLICY", McpConfiguration.environmentVariableFor("write-policy")); + assertEquals( + "NOSTR_MCP_LIMITS_MAX_EVENTS_PER_QUERY", + McpConfiguration.environmentVariableFor("limits.max-events-per-query")); + } + + private Set settingsReadByTheCode() { + Matcher matcher = + SETTING_IN_CODE.matcher(read(Path.of("src/main/java/nostr/mcp/McpConfiguration.java"))); + Set settings = new java.util.LinkedHashSet<>(); + while (matcher.find()) { + settings.add(matcher.group(1)); + } + return settings; + } + + private void set(String property, String value) { + System.setProperty(property, value); + propertiesSet.add(property); + } + + private String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java new file mode 100644 index 00000000..04bcc2e9 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java @@ -0,0 +1,302 @@ +package nostr.mcp; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Launches the server the way an MCP host does and drives it as a real client. + * + *

Unit tests can prove a tool computes the right answer while the server never speaks the + * protocol at all: the transport, the JSON framing, the capability handshake and the process + * lifecycle are all outside them. This test is the acceptance path, running the built classes + * in a separate JVM over stdin and stdout, exactly as Claude Desktop or an IDE agent would. + * + *

It also guards a failure mode unique to stdio: anything written to standard output that is + * not a protocol frame corrupts the stream. A logging line in the wrong place breaks every host, + * and only an end-to-end run catches it. + */ +class NostrMcpServerStdioIT { + + private static final Duration STARTUP_TIMEOUT = Duration.ofSeconds(30); + private static final String UNREACHABLE_RELAY = "ws://localhost:1"; + + // Verifies a host can launch the server, complete the handshake, and discover the tool + // surface, which is the whole point of the module existing. + @Test + void aHostCanLaunchTheServerAndDiscoverItsTools() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + ListToolsResult tools = client.listTools(); + + assertEquals( + List.of( + "nostr_list_relays", + "nostr_list_identities", + "nostr_query_events", + "nostr_get_profile", + "nostr_relay_info", + "nostr_subscribe", + "nostr_read_subscription", + "nostr_list_subscriptions", + "nostr_unsubscribe", + "nostr_fetch_thread", + "nostr_get_contacts", + "nostr_read_direct_messages", + "nostr_publish_note", + "nostr_publish_event", + "nostr_update_profile", + "nostr_send_direct_message", + "nostr_create_identity", + "nostr_import_identity", + "nostr_rename_identity", + "nostr_set_default_identity", + "nostr_export_identity_backup", + "nostr_remove_identity"), + tools.tools().stream().map(Tool::name).toList()); + } + } + + // Verifies the discovered tool carries a description and an input schema, since a host shows + // the first to a model and validates against the second. + @Test + void theDiscoveredToolDescribesItself() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + Tool tool = client.listTools().tools().getFirst(); + + assertNotNull(tool.description()); + assertFalse(tool.description().isBlank()); + assertNotNull(tool.inputSchema()); + } + } + + // Verifies calling the tool over the protocol returns a usable answer, proving the round trip + // from host to tool and back rather than only that the tool exists. + @Test + void aHostCanCallTheToolAndReadTheAnswer() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + CallToolResult result = + client.callTool(new CallToolRequest("nostr_list_relays", java.util.Map.of())); + + assertFalse(Boolean.TRUE.equals(result.isError()), "the tool reported an error"); + assertTrue(summaryOf(result).contains("relays connected"), summaryOf(result)); + assertNotNull(result.structuredContent()); + } + } + + // Verifies a read-only server exposes no write tool over the real protocol, so the policy + // holds where a host actually sees it rather than only in the registry. + @Test + void aReadOnlyServerOffersNoWriteToolsToAHost() { + try (McpSyncClient client = launchServer("-Dnostr.mcp.write-policy=deny")) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.stream().noneMatch(name -> name.contains("publish")), tools.toString()); + assertTrue(tools.stream().noneMatch(name -> name.contains("update")), tools.toString()); + assertTrue(tools.contains("nostr_query_events"), tools.toString()); + } + } + + // Verifies the server can be configured from the environment as well as from properties, + // which is how a container configures it: an image sets variables, not JVM flags. + @Test + void theServerCanBeConfiguredFromTheEnvironment() { + try (McpSyncClient client = launchServerWithEnvironment(Map.of("NOSTR_MCP_WRITE_POLICY", "deny"))) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.stream().noneMatch(name -> name.contains("publish")), tools.toString()); + assertTrue(tools.contains("nostr_query_events"), tools.toString()); + } + } + + // Verifies the guided prompts reach a host, since a tool surface with no guidance makes an + // agent learn by trial and error on a medium where mistakes are permanent and public. + @Test + void aHostCanDiscoverTheGuidedPrompts() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + List prompts = + client.listPrompts().prompts().stream() + .map(io.modelcontextprotocol.spec.McpSchema.Prompt::name) + .toList(); + + assertEquals(List.of("compose-note", "catch-up-feed", "watch-mentions"), prompts); + } + } + + // Verifies a prompt returns usable instructions rather than an empty shell, and that the + // publishing one names the confirmation step models most often skip. + @Test + void theComposePromptTeachesTheConfirmationStep() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + String instructions = + client + .getPrompt( + new io.modelcontextprotocol.spec.McpSchema.GetPromptRequest( + "compose-note", java.util.Map.of("topic", "a test note"))) + .messages() + .stream() + .map(message -> ((TextContent) message.content()).text()) + .findFirst() + .orElse(""); + + assertTrue(instructions.contains("a test note"), instructions); + assertTrue(instructions.contains("confirmationToken"), instructions); + assertTrue(instructions.contains("cannot be reliably deleted"), instructions); + } + } + + // Verifies an agent can read the server's own identity and relay configuration as resources, + // without spending a tool call on something that never changes mid-conversation. + @Test + void aHostCanReadTheServersOwnContextAsResources() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + List resourceUris = + client.listResources().resources().stream() + .map(io.modelcontextprotocol.spec.McpSchema.Resource::uri) + .toList(); + + assertTrue(resourceUris.contains("nostr://identity/{alias}"), resourceUris.toString()); + assertTrue(resourceUris.contains("nostr://relay/{name}"), resourceUris.toString()); + } + } + + // Verifies the server offers no NIP-04 tool, since NIP-04 exposes both correspondents and the + // conversation to every relay and is unsuitable for a tool an agent drives on someone's behalf. + @Test + void noLegacyUnencryptedDirectMessageToolIsOffered() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.stream().noneMatch(name -> name.contains("nip04")), tools.toString()); + assertTrue(tools.contains("nostr_send_direct_message"), tools.toString()); + } + } + + // Verifies a server told not to mutate its keystore offers no lifecycle tool to a host, so + // identity-policy is enforced where a host actually looks rather than only in the registry. + @Test + void aServerForbiddenToMutateIdentitiesOffersNoLifecycleTools() { + try (McpSyncClient client = launchServer("-Dnostr.mcp.identity-policy=deny")) { + client.initialize(); + + List tools = client.listTools().tools().stream().map(Tool::name).toList(); + + assertTrue(tools.stream().noneMatch(name -> name.contains("identity") + && !name.equals("nostr_list_identities")), tools.toString()); + assertTrue(tools.contains("nostr_publish_note"), tools.toString()); + } + } + + // Verifies an agent can discover which identities the server signs with, and that an empty + // keystore is reported as a usable state rather than a startup failure. + @Test + void aHostCanSeeWhichIdentitiesTheServerHolds() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + CallToolResult result = + client.callTool(new CallToolRequest("nostr_list_identities", java.util.Map.of())); + + assertFalse(Boolean.TRUE.equals(result.isError()), "listing identities reported an error"); + assertTrue(summaryOf(result).contains("No identities are configured"), summaryOf(result)); + } + } + + + // Verifies the server starts and serves even when no relay can be reached, since an MCP host + // launches it before anyone has checked the network, and a server that dies on a dead relay + // is one a host reports as broken. + @Test + void theServerServesEvenWhenNoRelayIsReachable() { + try (McpSyncClient client = launchServer()) { + client.initialize(); + + CallToolResult result = + client.callTool(new CallToolRequest("nostr_list_relays", java.util.Map.of())); + + assertTrue(summaryOf(result).startsWith("0 of "), summaryOf(result)); + } + } + + /** + * Starts the packaged server in its own JVM, pointed at a relay that does not exist. + * + *

An unreachable relay is deliberate: this test is about the protocol, and a real relay + * would make it depend on Docker for no benefit. It doubles as the evidence that the server + * survives a dead relay set. + */ + private McpSyncClient launchServerWithEnvironment(Map environment) { + ServerParameters parameters = + ServerParameters.builder("java") + .args( + "-Dnostr.mcp.relays.read=" + UNREACHABLE_RELAY, + "-cp", + runtimeClasspath(), + NostrMcpApplication.class.getName()) + .env(environment) + .build(); + + return McpClient.sync(new StdioClientTransport(parameters, McpJsonDefaults.getMapper())) + .requestTimeout(STARTUP_TIMEOUT) + .build(); + } + + private McpSyncClient launchServer(String... extraProperties) { + List arguments = new java.util.ArrayList<>(); + arguments.add("-Dnostr.mcp.relays.read=" + UNREACHABLE_RELAY); + arguments.addAll(List.of(extraProperties)); + arguments.add("-cp"); + arguments.add(runtimeClasspath()); + arguments.add(NostrMcpApplication.class.getName()); + + ServerParameters parameters = + ServerParameters.builder("java").args(arguments).build(); + + return McpClient.sync(new StdioClientTransport(parameters, McpJsonDefaults.getMapper())) + .requestTimeout(STARTUP_TIMEOUT) + .build(); + } + + /** The classpath this test is running with, which already contains the module and the SDK. */ + private String runtimeClasspath() { + return System.getProperty("java.class.path"); + } + + private String summaryOf(CallToolResult result) { + return ((TextContent) result.content().getFirst()).text(); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/ServerVersionTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/ServerVersionTest.java new file mode 100644 index 00000000..28570539 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/ServerVersionTest.java @@ -0,0 +1,58 @@ +package nostr.mcp; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the version a host is told matches the version that was built. + * + *

It was previously a constant in the source, which is a copy of the pom that nothing keeps + * in step: the next release would have reported the previous version to every host, and nothing + * would have failed. + */ +class ServerVersionTest { + + private static final Pattern PARENT_VERSION = + Pattern.compile(".*?([^<]+)", Pattern.DOTALL); + + // Verifies the reported version is the one in the pom, so a release cannot ship announcing + // the version before it. + @Test + void theReportedVersionIsTheBuiltVersion() { + assertEquals(versionFromPom(), ServerVersion.current()); + } + + // Verifies the version was actually filtered in, rather than the placeholder surviving into + // the jar, which would tell every host the literal text of a Maven property. + @Test + void theVersionWasFilteredRatherThanLeftAsAPlaceholder() { + String version = ServerVersion.current(); + + assertNotEquals("unknown", version, "the build resource is missing from the classpath"); + assertTrue(version.matches("\\d+\\.\\d+\\.\\d+(-SNAPSHOT)?"), "not a version: " + version); + } + + private String versionFromPom() { + Matcher matcher = PARENT_VERSION.matcher(read(Path.of("pom.xml"))); + assertTrue(matcher.find(), "the module pom has no parent version"); + return matcher.group(1); + } + + private String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/argument/NostrIdentifierTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/argument/NostrIdentifierTest.java new file mode 100644 index 00000000..913d820d --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/argument/NostrIdentifierTest.java @@ -0,0 +1,104 @@ +package nostr.mcp.argument; + +import nostr.id.Identity; +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies identifiers are accepted in whichever form a user pasted, and refused clearly. */ +class NostrIdentifierTest { + + // Verifies a hex public key is accepted, since that is what a relay speaks. + @Test + void aHexPublicKeyIsAccepted() { + String hex = Identity.generateRandomIdentity().getPublicKey().toHexString(); + + assertEquals(hex, NostrIdentifier.publicKey("pubkey", hex).hex()); + } + + // Verifies an npub is accepted and decoded to the same key, since that is what a person + // copies out of a client. + @Test + void anNpubDecodesToTheSameKey() { + Identity identity = Identity.generateRandomIdentity(); + String hex = identity.getPublicKey().toHexString(); + String npub = identity.getPublicKey().toBech32String(); + + assertEquals(hex, NostrIdentifier.publicKey("pubkey", npub).hex()); + } + + // Verifies uppercase hex and surrounding whitespace are tolerated, because a pasted value + // frequently carries both and neither changes the key. + @Test + void pastedFormattingIsTolerated() { + String hex = Identity.generateRandomIdentity().getPublicKey().toHexString(); + + assertEquals(hex, NostrIdentifier.publicKey("pubkey", " " + hex.toUpperCase() + "\n").hex()); + } + + // Verifies an nsec in a public argument is refused with advice to rotate, since by the time a + // tool sees one the key has already been exposed to the model. + @Test + void aPrivateKeyInAPublicArgumentIsRefusedWithAdvice() { + String nsec = Identity.generateRandomIdentity().getPrivateKey().toBech32String(); + + ToolException refused = + assertThrows(ToolException.class, () -> NostrIdentifier.publicKey("pubkey", nsec)); + + assertEquals(ToolFailure.INVALID_ARGUMENT, refused.getFailure()); + assertTrue(refused.getMessage().contains("compromised"), refused.getMessage()); + } + + // Verifies an npub given where an event id belongs names the confusion, since both forms are + // bech32 of the same length and "decode failed" would not help. + @Test + void anNpubWhereAnEventIdBelongsNamesTheMistake() { + String npub = Identity.generateRandomIdentity().getPublicKey().toBech32String(); + + ToolException refused = + assertThrows(ToolException.class, () -> NostrIdentifier.eventId("id", npub)); + + assertTrue(refused.getMessage().contains("note"), refused.getMessage()); + } + + // Verifies a value that is neither form is refused rather than silently truncated. + @Test + void somethingThatIsNeitherFormIsRefused() { + ToolException refused = + assertThrows(ToolException.class, () -> NostrIdentifier.publicKey("pubkey", "wat")); + + assertEquals(ToolFailure.INVALID_ARGUMENT, refused.getFailure()); + } + + // Verifies an empty argument is named, so an agent that omitted a value learns which one. + @Test + void anEmptyArgumentIsNamed() { + ToolException refused = + assertThrows(ToolException.class, () -> NostrIdentifier.publicKey("author", " ")); + + assertTrue(refused.getMessage().contains("author"), refused.getMessage()); + } + + // Verifies hex of the wrong length is refused, since a truncated key would otherwise query + // for events that can never match. + @Test + void hexOfTheWrongLengthIsRefused() { + assertThrows(ToolException.class, () -> NostrIdentifier.publicKey("pubkey", "abcdef")); + } + + // Verifies the decoded identifier can be used as a public key for addressing. + @Test + void aDecodedIdentifierIsUsableAsAPublicKey() { + Identity identity = Identity.generateRandomIdentity(); + + assertEquals( + identity.getPublicKey().toHexString(), + NostrIdentifier.publicKey("pubkey", identity.getPublicKey().toBech32String()) + .asPublicKey() + .toHexString()); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/argument/TimeArgumentTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/argument/TimeArgumentTest.java new file mode 100644 index 00000000..1ad3d2da --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/argument/TimeArgumentTest.java @@ -0,0 +1,76 @@ +package nostr.mcp.argument; + +import nostr.mcp.tool.ToolException; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies every timestamp form a caller might use normalises to Unix seconds. */ +class TimeArgumentTest { + + private static final Instant NOW = Instant.parse("2026-01-15T12:00:00Z"); + private static final Clock FIXED = Clock.fixed(NOW, ZoneOffset.UTC); + + // Verifies a relative age is resolved against now, which is how a model expresses "recently". + @Test + void aRelativeAgeIsResolvedAgainstNow() { + assertEquals(NOW.minus(Duration.ofHours(24)).getEpochSecond(), seconds("24h")); + assertEquals(NOW.minus(Duration.ofDays(7)).getEpochSecond(), seconds("7d")); + assertEquals(NOW.minus(Duration.ofMinutes(30)).getEpochSecond(), seconds("30m")); + assertEquals(NOW.minus(Duration.ofSeconds(45)).getEpochSecond(), seconds("45s")); + assertEquals(NOW.minus(Duration.ofDays(14)).getEpochSecond(), seconds("2w")); + } + + // Verifies a relative age is read as an age rather than a future time, since no relay holds + // events that have not happened yet. + @Test + void aRelativeAgeIsInThePast() { + assertTrue(seconds("1h") < NOW.getEpochSecond()); + } + + // Verifies an ISO-8601 instant is accepted, since that is what a precise caller writes. + @Test + void anIsoInstantIsAccepted() { + assertEquals( + Instant.parse("2026-01-01T00:00:00Z").getEpochSecond(), seconds("2026-01-01T00:00:00Z")); + } + + // Verifies a bare date means the start of that day, so a "since" includes the whole day the + // user named. + @Test + void aBareDateMeansTheStartOfThatDay() { + assertEquals(Instant.parse("2026-01-01T00:00:00Z").getEpochSecond(), seconds("2026-01-01")); + } + + // Verifies Unix seconds pass through, since an agent may echo back a value a tool returned. + @Test + void unixSecondsPassThrough() { + assertEquals(1767225600L, seconds("1767225600")); + } + + // Verifies whitespace and case in a relative expression are tolerated. + @Test + void relativeFormattingIsTolerated() { + assertEquals(seconds("24h"), seconds(" 24H ")); + } + + // Verifies an unparseable value explains the accepted forms rather than failing mutely. + @Test + void anUnparseableValueExplainsTheAcceptedForms() { + ToolException refused = assertThrows(ToolException.class, () -> seconds("last tuesday")); + + assertTrue(refused.getMessage().contains("24h"), refused.getMessage()); + assertTrue(refused.getMessage().contains("since"), refused.getMessage()); + } + + private long seconds(String value) { + return TimeArgument.toUnixSeconds("since", value, FIXED); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/cli/KeyAdminCliTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/cli/KeyAdminCliTest.java new file mode 100644 index 00000000..7311e1af --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/cli/KeyAdminCliTest.java @@ -0,0 +1,192 @@ +package nostr.mcp.cli; + +import nostr.mcp.identity.IdentityStore; +import nostr.mcp.identity.KeystoreException; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies key administration works for a human and never prints a private key. */ +class KeyAdminCliTest { + + private final RecordingStore store = new RecordingStore(); + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Verifies keygen creates a usable identity and reports its public half. + @Test + void keygenCreatesAnIdentityAndReportsItsPublicKey() { + int exitCode = run("keygen", "personal"); + + assertEquals(0, exitCode); + assertEquals(List.of("personal"), store.aliases()); + assertTrue(printed().contains("npub1"), printed()); + } + + // Verifies the generated private key never reaches the terminal, since a printed key ends up + // in scrollback and shell history by default. + @Test + void keygenNeverPrintsThePrivateKey() { + run("keygen", "personal"); + + String privateKeyHex = HexFormat.of().formatHex(store.keyFor("personal")); + assertFalse(printed().contains(privateKeyHex), "the private key was printed"); + assertFalse(printed().contains("nsec"), "an nsec was printed"); + } + + // Verifies a key is read from standard input rather than an argument, since arguments are + // visible to every user on the host through the process table. + @Test + void importReadsAHexKeyFromStandardInput() { + byte[] key = randomKeyBytes(); + + int exitCode = runWithInput(HexFormat.of().formatHex(key), "import", "personal"); + + assertEquals(0, exitCode); + assertArrayEquals(key, store.keyFor("personal")); + } + + // Verifies the nsec form a person actually holds is accepted, not only raw hex. + @Test + void importAcceptsAnNsec() { + byte[] key = randomKeyBytes(); + String nsec = new nostr.base.PrivateKey(key).toBech32String(); + + int exitCode = runWithInput(nsec, "import", "personal"); + + assertEquals(0, exitCode); + assertArrayEquals(key, store.keyFor("personal")); + } + + // Verifies unreadable input is refused with a message about the input rather than a stack trace. + @Test + void importRefusesSomethingThatIsNotAKey() { + int exitCode = runWithInput("not-a-key", "import", "personal"); + + assertNotEquals(0, exitCode); + assertTrue(printed().contains("neither hex nor an nsec"), printed()); + assertTrue(store.aliases().isEmpty()); + } + + // Verifies list shows what exists and says how to start when nothing does. + @Test + void listShowsTheIdentitiesAndGuidesAnEmptyKeystore() { + assertEquals(0, run("list")); + assertTrue(printed().contains("keygen"), printed()); + + run("keygen", "personal"); + output.reset(); + + assertEquals(0, run("list")); + assertTrue(printed().contains("personal"), printed()); + } + + // Verifies removing an identity reports that it is irreversible, and that removing a name + // that never existed is distinguishable from removing a real one. + @Test + void removeReportsWhetherAnythingWasActuallyRemoved() { + run("keygen", "personal"); + output.reset(); + + assertEquals(0, run("remove", "personal")); + assertTrue(printed().contains("cannot be undone"), printed()); + assertTrue(store.aliases().isEmpty()); + + output.reset(); + assertNotEquals(0, run("remove", "personal")); + assertTrue(printed().contains("No identity called"), printed()); + } + + // Verifies an existing alias is never silently overwritten, since replacing a key destroys + // the account it belonged to with no way back. + @Test + void keygenRefusesToOverwriteAnExistingAlias() { + run("keygen", "personal"); + byte[] original = store.keyFor("personal").clone(); + output.reset(); + + assertNotEquals(0, run("keygen", "personal")); + assertArrayEquals(original, store.keyFor("personal")); + } + + // Verifies an unknown or missing command explains the real commands rather than failing mutely. + @Test + void anUnknownCommandExplainsTheRealOnes() { + assertNotEquals(0, run("frobnicate")); + + String help = printed(); + assertTrue(help.contains("keygen"), help); + assertTrue(help.contains("import"), help); + assertTrue(help.contains("list"), help); + assertTrue(help.contains("remove"), help); + } + + private int run(String... arguments) { + return runWithInput("", arguments); + } + + private int runWithInput(String standardInput, String... arguments) { + InputStream original = System.in; + System.setIn(new ByteArrayInputStream(standardInput.getBytes(StandardCharsets.UTF_8))); + try { + return new KeyAdminCli(store, new PrintStream(output, true, StandardCharsets.UTF_8)) + .run(List.of(arguments)); + } finally { + System.setIn(original); + } + } + + private String printed() { + return output.toString(StandardCharsets.UTF_8); + } + + private static byte[] randomKeyBytes() { + return HexFormat.of() + .parseHex(nostr.id.Identity.generateRandomIdentity().getPrivateKey().toHexString()); + } + + private static void assertArrayEquals(byte[] expected, byte[] actual) { + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual); + } + + /** An in-memory store, so the tests exercise the CLI rather than a keychain. */ + private static final class RecordingStore implements IdentityStore { + + private final Map keys = new LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + if (keys.containsKey(alias)) { + throw new KeystoreException("The keystore already holds an identity called '" + alias + "'"); + } + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return new ArrayList<>(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + + byte[] keyFor(String alias) { + return keys.get(alias); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/guidance/NostrPromptsTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/guidance/NostrPromptsTest.java new file mode 100644 index 00000000..2244b7d2 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/guidance/NostrPromptsTest.java @@ -0,0 +1,134 @@ +package nostr.mcp.guidance; + +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the prompts teach the sequences that models actually get wrong. + * + *

Each assertion here corresponds to a specific mistake: publishing without confirming, + * querying the whole network instead of the follow list, and reading "nothing yet" as "nothing + * matched". A prompt that omits its warning is a prompt that has stopped doing its job. + */ +class NostrPromptsTest { + + private static final Path GUIDE = Path.of("../docs/howto/run-the-mcp-server.md"); + + // Verifies the composing prompt names the confirmation step, which is the step a model most + // often skips and the one that makes a hallucinated post a no-op. + @Test + void theComposingPromptNamesTheConfirmationStep() { + String instructions = instructionsFor("compose-note", Map.of("topic", "anything")); + + assertTrue(instructions.contains("confirmationToken"), instructions); + assertTrue(instructions.contains("Do not invent"), instructions); + } + + // Verifies it warns against republishing a partially-accepted note, since retrying a write + // that already landed is worse on a permanent medium than the incomplete send. + @Test + void theComposingPromptWarnsAgainstRepublishing() { + String instructions = instructionsFor("compose-note", Map.of("topic", "anything")); + + assertTrue(instructions.contains("do not send it again"), instructions); + } + + // Verifies the feed prompt starts from the follow list, since a model left to itself queries + // broadly and then filters, which returns strangers and misses the people followed. + @Test + void theFeedPromptStartsFromTheFollowList() { + String instructions = instructionsFor("catch-up-feed", Map.of()); + + assertTrue(instructions.indexOf("nostr_get_contacts") < instructions.indexOf("nostr_query_events"), + "the prompt should read contacts before querying"); + assertTrue(instructions.contains("truncated"), instructions); + assertTrue(instructions.contains("timedOut"), instructions); + } + + // Verifies the feed prompt uses the caller's time range, and has a sensible one when none is + // given, so the tool is usable with no arguments. + @Test + void theFeedPromptUsesTheGivenTimeRange() { + assertTrue(instructionsFor("catch-up-feed", Map.of("since", "7d")).contains("7d")); + assertTrue(instructionsFor("catch-up-feed", Map.of()).contains("24h")); + } + + // Verifies the watching prompt distinguishes a replaying backlog from an empty result, which + // is the specific way an agent reports "nobody mentioned you" when it simply read too early. + @Test + void theWatchingPromptDistinguishesReplayingFromEmpty() { + String instructions = instructionsFor("watch-mentions", Map.of()); + + assertTrue(instructions.contains("backlogDrained"), instructions); + assertTrue(instructions.contains("not \"nothing mentions you\""), instructions); + assertTrue(instructions.contains("droppedCount"), instructions); + } + + // Verifies every prompt is described in the guide, so the documented list cannot fall behind + // the prompts the server actually offers. + @Test + void everyPromptIsDocumented() { + String guide = read(GUIDE); + + for (SyncPromptSpecification prompt : NostrPrompts.all()) { + assertTrue( + guide.contains("`" + prompt.prompt().name() + "`"), + prompt.prompt().name() + " is registered but absent from the guide"); + } + } + + // Verifies each prompt describes itself, since a host shows the description to a user choosing + // between them. + @Test + void everyPromptDescribesItself() { + for (SyncPromptSpecification prompt : NostrPrompts.all()) { + assertFalse(prompt.prompt().description().isBlank(), prompt.prompt().name() + " has no description"); + assertFalse(prompt.prompt().title().isBlank(), prompt.prompt().name() + " has no title"); + } + } + + // Verifies the prompts are the three the specification names, in a stable order. + @Test + void theExpectedPromptsAreOffered() { + assertEquals( + List.of("compose-note", "catch-up-feed", "watch-mentions"), + NostrPrompts.all().stream().map(prompt -> prompt.prompt().name()).toList()); + } + + private String instructionsFor(String name, Map arguments) { + SyncPromptSpecification prompt = + NostrPrompts.all().stream() + .filter(candidate -> candidate.prompt().name().equals(name)) + .findFirst() + .orElseThrow(); + return prompt + .promptHandler() + .apply(null, new GetPromptRequest(name, arguments)) + .messages() + .stream() + .map(message -> ((TextContent) message.content()).text()) + .findFirst() + .orElse(""); + } + + private String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/identity/EncryptedFileKeySourceTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/identity/EncryptedFileKeySourceTest.java new file mode 100644 index 00000000..2ebacd7e --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/identity/EncryptedFileKeySourceTest.java @@ -0,0 +1,157 @@ +package nostr.mcp.identity; + +import nostr.id.Identity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the portable keystore against a real file. + * + *

Exercised through the real {@code PKCS12} implementation rather than a stand-in, because the + * things most likely to be wrong here, which algorithms the format accepts and which permissions + * the file ends up with, are precisely the things a fake would have to invent. + */ +class EncryptedFileKeySourceTest { + + private static final char[] PASSPHRASE = "correct horse battery staple".toCharArray(); + + @TempDir Path directory; + + // Verifies a stored key can be read back byte for byte, which is the backend's entire job. + @Test + void aStoredKeyIsReadBackUnchanged() { + byte[] key = randomKey(); + EncryptedFileKeySource source = source(); + + source.store("personal", key); + + assertArrayEquals(key, source().loadKeys(IdentityBinding.unbound()).get("personal")); + } + + // Verifies a missing keystore is an empty one rather than an error, since a first run has no + // file yet and should still start. + @Test + void aMissingKeystoreHoldsNothing() { + assertTrue(source().loadKeys(IdentityBinding.unbound()).isEmpty()); + assertTrue(source().aliases().isEmpty()); + } + + // Verifies a bound process decrypts only its own entry, so another identity's key never + // enters the process at all. + @Test + void aBoundProcessReadsOnlyItsOwnEntry() { + EncryptedFileKeySource source = source(); + source.store("personal", randomKey()); + source.store("project-bot", randomKey()); + + Map keys = source().loadKeys(IdentityBinding.to("personal")); + + assertEquals(Set.of("personal"), keys.keySet()); + } + + // Verifies the aliases can be listed without decrypting, which is what lets a bound process + // know what exists while reading only its own key. + @Test + void aliasesAreListedWithoutDecrypting() { + EncryptedFileKeySource source = source(); + source.store("personal", randomKey()); + source.store("project-bot", randomKey()); + + assertEquals(List.of("personal", "project-bot"), source().aliases()); + } + + // Verifies an existing alias is never silently replaced, since overwriting a key destroys the + // account it belonged to. + @Test + void anExistingAliasIsNotOverwritten() { + byte[] original = randomKey(); + source().store("personal", original); + + assertThrows(KeystoreException.class, () -> source().store("personal", randomKey())); + assertArrayEquals(original, source().loadKeys(IdentityBinding.unbound()).get("personal")); + } + + // Verifies removal really removes, and that removing an absent alias is reported rather than + // silently succeeding. + @Test + void removalIsReportedHonestly() { + source().store("personal", randomKey()); + + assertTrue(source().remove("personal")); + assertFalse(source().remove("personal")); + assertTrue(source().loadKeys(IdentityBinding.unbound()).isEmpty()); + } + + // Verifies the keystore is created readable only by its owner, since the passphrase is the + // only other protection it has. + @Test + void theKeystoreIsCreatedReadableOnlyByItsOwner() throws IOException { + source().store("personal", randomKey()); + + assertEquals( + Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(keystorePath())); + } + + // Verifies a keystore other users can read is refused rather than warned about, because a file + // an attacker can copy is one they can attack offline for as long as they like. + @Test + void aWorldReadableKeystoreIsRefused() throws IOException { + source().store("personal", randomKey()); + Files.setPosixFilePermissions( + keystorePath(), + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OTHERS_READ)); + + KeystoreException refused = + assertThrows( + KeystoreException.class, () -> source().loadKeys(IdentityBinding.unbound())); + + assertTrue(refused.getMessage().contains("chmod 600"), refused.getMessage()); + } + + // Verifies a wrong passphrase says so, rather than reporting an empty keystore and letting a + // server start with no identities for no visible reason. + @Test + void aWrongPassphraseIsReportedClearly() { + source().store("personal", randomKey()); + + EncryptedFileKeySource wrong = + new EncryptedFileKeySource(keystorePath(), "wrong".toCharArray()); + + KeystoreException failure = + assertThrows(KeystoreException.class, () -> wrong.loadKeys(IdentityBinding.unbound())); + + assertTrue(failure.getMessage().contains("passphrase"), failure.getMessage()); + } + + private EncryptedFileKeySource source() { + return new EncryptedFileKeySource(keystorePath(), PASSPHRASE); + } + + private Path keystorePath() { + return directory.resolve("keys.p12"); + } + + private byte[] randomKey() { + return HexFormat.of() + .parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityLifecycleTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityLifecycleTest.java new file mode 100644 index 00000000..0dfb7594 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityLifecycleTest.java @@ -0,0 +1,230 @@ +package nostr.mcp.identity; + +import nostr.id.Identity; +import nostr.mcp.tool.ToolException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies the keystore and the running vault stay in step through every lifecycle change. */ +class IdentityLifecycleTest { + + @TempDir Path directory; + + private final RecordingStore store = new RecordingStore(); + + // Verifies a created identity is usable immediately and survives a restart, which needs both + // the vault and the store to have been updated. + @Test + void aCreatedIdentityIsInBothTheVaultAndTheStore() { + try (IdentityVault vault = emptyVault()) { + IdentitySummary created = new IdentityLifecycle(vault, store).create("personal"); + + assertEquals("personal", created.alias()); + assertTrue(created.npub().startsWith("npub")); + assertTrue(vault.find("personal").isPresent(), "the vault cannot sign with it"); + assertEquals(List.of("personal"), store.aliases(), "it would vanish on restart"); + } + } + + // Verifies a created identity returns no private key, since an agent should be able to create + // an account it can use but cannot leak. + @Test + void creatingReturnsNoPrivateKey() { + try (IdentityVault vault = emptyVault()) { + IdentitySummary created = new IdentityLifecycle(vault, store).create("personal"); + + String privateKeyHex = HexFormat.of().formatHex(store.keyFor("personal")); + assertFalse(created.toString().contains(privateKeyHex), created.toString()); + assertEquals(3, IdentitySummary.class.getRecordComponents().length); + } + } + + // Verifies an unusable alias is refused, since aliases appear in resource URIs where a slash + // or a space would address something else entirely. + @Test + void anAliasThatWouldBreakAUriIsRefused() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + + assertThrows(ToolException.class, () -> lifecycle.create("has spaces")); + assertThrows(ToolException.class, () -> lifecycle.create("has/slash")); + assertThrows(ToolException.class, () -> lifecycle.create("UPPER")); + assertThrows(ToolException.class, () -> lifecycle.create("")); + assertTrue(store.aliases().isEmpty()); + } + } + + // Verifies a key is imported from a file the server reads itself, which is how an import + // happens without the key passing through the model. + @Test + void aKeyIsImportedFromAFileTheServerReads() throws Exception { + Path keyFile = directory.resolve("key.txt"); + String keyHex = Identity.generateRandomIdentity().getPrivateKey().toHexString(); + Files.writeString(keyFile, keyHex); + + try (IdentityVault vault = emptyVault()) { + IdentitySummary imported = + new IdentityLifecycle(vault, store).importFrom("personal", "file:" + keyFile); + + assertEquals( + Identity.create(new nostr.base.PrivateKey(HexFormat.of().parseHex(keyHex))) + .getPublicKey() + .toHexString(), + imported.publicKey()); + } + } + + // Verifies key material given as the source is refused with advice to rotate, since by then + // the key is already in the conversation. + @Test + void keyMaterialAsTheSourceIsRefusedWithAdvice() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + String nsec = Identity.generateRandomIdentity().getPrivateKey().toBech32String(); + + ToolException refused = + assertThrows(ToolException.class, () -> lifecycle.importFrom("personal", nsec)); + + assertTrue(refused.getMessage().contains("compromised"), refused.getMessage()); + assertTrue(store.aliases().isEmpty()); + } + } + + // Verifies renaming keeps the same key, so the account is untouched and only the local label + // changes. + @Test + void renamingKeepsTheSameKey() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + String publicKey = lifecycle.create("old-name").publicKey(); + + IdentitySummary renamed = lifecycle.rename("old-name", "new-name"); + + assertEquals(publicKey, renamed.publicKey()); + assertEquals(List.of("new-name"), store.aliases()); + assertTrue(vault.find("old-name").isEmpty()); + } + } + + // Verifies a backup is written encrypted and readable only by its owner, since a backup is a + // copy of the key and a readable one is a key an attacker can take away. + @Test + void aBackupIsEncryptedAndOwnerOnly() throws Exception { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + lifecycle.create("personal"); + Path backup = directory.resolve("backup.p12"); + + lifecycle.exportBackup("personal", backup, "passphrase".toCharArray()); + + assertTrue(Files.exists(backup)); + assertEquals( + Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(backup)); + String contents = new String(Files.readAllBytes(backup), java.nio.charset.StandardCharsets.ISO_8859_1); + assertFalse( + contents.contains(HexFormat.of().formatHex(store.keyFor("personal"))), + "the key is in the backup in plaintext"); + } + } + + // Verifies exporting a backup is what makes removal permissible, since removal without one is + // the single unrecoverable action in the module. + @Test + void aBackupIsRecordedSoRemovalKnowsAboutIt() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + lifecycle.create("personal"); + + assertFalse(lifecycle.hasBackup("personal")); + lifecycle.exportBackup("personal", directory.resolve("b.p12"), "pass".toCharArray()); + assertTrue(lifecycle.hasBackup("personal")); + } + } + + // Verifies removal clears the identity from the vault and the store, so it cannot sign now and + // does not reappear after a restart. + @Test + void removalClearsBothTheVaultAndTheStore() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + lifecycle.create("personal"); + + lifecycle.remove("personal"); + + assertTrue(vault.find("personal").isEmpty()); + assertTrue(store.aliases().isEmpty()); + } + } + + // Verifies a taken alias is refused rather than silently replacing an existing key, which + // would destroy an account with no warning. + @Test + void anExistingAliasIsNotOverwritten() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, store); + String original = lifecycle.create("personal").publicKey(); + + assertThrows(ToolException.class, () -> lifecycle.create("personal")); + assertEquals(original, vault.publicKeyOf("personal").toHexString()); + } + } + + private IdentityVault emptyVault() { + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + /** A store that records what it was told, standing in for a keystore file. */ + private static final class RecordingStore implements IdentityStore { + private final Map keys = new LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + if (keys.containsKey(alias)) { + throw new KeystoreException("already holds '" + alias + "'"); + } + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return new ArrayList<>(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + + byte[] keyFor(String alias) { + return keys.get(alias); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityVaultTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityVaultTest.java new file mode 100644 index 00000000..fbc5d188 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/identity/IdentityVaultTest.java @@ -0,0 +1,231 @@ +package nostr.mcp.identity; + +import nostr.base.PublicKey; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies the vault signs without ever handing out the key that signs. */ +class IdentityVaultTest { + + // Verifies the vault unlocks the keys its source holds and reports them by alias. + @Test + void everyKeyTheSourceHoldsIsUnlocked() { + try (IdentityVault vault = vaultOf("personal", "project-bot")) { + assertEquals( + List.of("personal", "project-bot"), + vault.list().stream().map(IdentitySummary::alias).toList()); + } + } + + // Verifies a summary carries only public material, which is what makes "no tool can return a + // key" a property of the type rather than a rule to remember. + @Test + void aSummaryCarriesOnlyPublicMaterial() { + try (IdentityVault vault = vaultOf("personal")) { + IdentitySummary summary = vault.list().getFirst(); + + assertEquals("personal", summary.alias()); + assertTrue(summary.npub().startsWith("npub")); + assertEquals(64, summary.publicKey().length()); + assertEquals(3, IdentitySummary.class.getRecordComponents().length); + } + } + + // Verifies signing happens inside the vault: the caller gets a signed event and no way to + // reach the key that signed it. + @Test + void signingHappensInsideTheVault() { + try (IdentityVault vault = vaultOf("personal")) { + GenericEvent event = noteBy(vault.publicKeyOf("personal")); + + vault.signAs("personal", event); + + assertNotNull(event.getSignature(), "the event was not signed"); + } + } + + // Verifies naming an unknown identity lists the ones that exist, so an agent can correct + // itself rather than guessing again. + @Test + void anUnknownIdentityListsTheKnownAliases() { + try (IdentityVault vault = vaultOf("personal")) { + IdentityUnknownException thrown = + assertThrows(IdentityUnknownException.class, () -> vault.publicKeyOf("nobody")); + + assertTrue(thrown.getMessage().contains("personal")); + assertEquals(List.of("personal"), List.copyOf(thrown.getKnownAliases())); + } + } + + // Verifies a lone identity becomes the default, since requiring a caller to name the only + // possible choice would be pedantry. + @Test + void aLoneIdentityBecomesTheDefault() { + try (IdentityVault vault = vaultOf("personal")) { + assertEquals("personal", vault.defaultAlias().orElseThrow()); + } + } + + // Verifies several identities with no explicit default stay ambiguous, because guessing which + // account to post from is a public and irreversible mistake. + @Test + void severalIdentitiesWithNoChoiceStayAmbiguous() { + try (IdentityVault vault = vaultOf("personal", "project-bot")) { + assertTrue(vault.defaultAlias().isEmpty()); + } + } + + // Verifies an explicit default is honoured when it names a real identity. + @Test + void anExplicitDefaultIsHonoured() { + try (IdentityVault vault = + new IdentityVault(sourceHolding("personal", "project-bot"), "project-bot")) { + assertEquals("project-bot", vault.defaultAlias().orElseThrow()); + } + } + + // Verifies a default naming an identity that does not exist fails at startup rather than at + // the first signing attempt, when an agent is already mid-task. + @Test + void aDefaultNamingNothingFailsAtStartup() { + assertThrows( + IdentityUnknownException.class, + () -> new IdentityVault(sourceHolding("personal"), "absent").close()); + } + + // Verifies closing the vault wipes its key material, shortening the window in which a heap + // dump would yield a key. + @Test + void closingWipesTheKeyMaterial() { + byte[] keyMaterial = randomKey(); + + new IdentityVault(sourceOf(Map.of("personal", keyMaterial)), null).close(); + + assertTrue(allZero(keyMaterial), "the key material survived close()"); + } + + // Verifies an empty keystore is an ordinary reportable state rather than a failure, since a + // first run legitimately has no keys yet. + @Test + void anEmptyKeystoreIsReportedRatherThanFatal() { + try (IdentityVault vault = new IdentityVault(sourceOf(Map.of()), null)) { + assertTrue(vault.isEmpty()); + assertTrue(vault.list().isEmpty()); + assertTrue(vault.defaultAlias().isEmpty()); + } + } + + // Verifies an identity can be looked up by alias for addressing, without unlocking signing. + @Test + void anIdentityCanBeFoundByAlias() { + try (IdentityVault vault = vaultOf("personal")) { + assertTrue(vault.find("personal").isPresent()); + assertFalse(vault.find("nobody").isPresent()); + } + } + + // Verifies a bound process holds only its own identity, so another alias is absent from the + // heap rather than merely refused. + @Test + void aBoundProcessUnlocksOnlyItsOwnIdentity() { + try (IdentityVault vault = + new IdentityVault(sourceHolding("personal", "project-bot"), null, IdentityBinding.to("personal"))) { + + assertEquals(List.of("personal"), vault.list().stream().map(IdentitySummary::alias).toList()); + assertTrue(vault.find("project-bot").isEmpty()); + assertThrows(IdentityUnknownException.class, () -> vault.publicKeyOf("project-bot")); + } + } + + // Verifies binding makes the identity unambiguous, so no caller has to name it. + @Test + void theBoundIdentityIsTheDefault() { + try (IdentityVault vault = + new IdentityVault(sourceHolding("personal", "project-bot"), null, IdentityBinding.to("project-bot"))) { + + assertEquals("project-bot", vault.defaultAlias().orElseThrow()); + } + } + + // Verifies a server bound to a missing identity fails at startup, where the typo can be fixed, + // rather than when an agent first tries to post. + @Test + void aBoundProcessRefusesToStartWithoutItsIdentity() { + KeySource source = sourceHolding("personal"); + + KeystoreException failure = + assertThrows( + KeystoreException.class, + () -> new IdentityVault(source, null, IdentityBinding.to("typo")).close()); + + assertTrue(failure.getMessage().contains("typo"), failure.getMessage()); + assertTrue(failure.getMessage().contains("keygen"), "the message should say how to fix it"); + } + + private IdentityVault vaultOf(String... aliases) { + return new IdentityVault(sourceHolding(aliases), null); + } + + private KeySource sourceHolding(String... aliases) { + LinkedHashMap keys = new LinkedHashMap<>(); + for (String alias : aliases) { + keys.put(alias, randomKey()); + } + return sourceOf(keys); + } + + /** + * A source holding exactly what a test hands it, and honouring the binding as a real backend + * must: a bound process never even reads the other entries. + */ + private KeySource sourceOf(Map keys) { + return new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + Map permitted = new LinkedHashMap<>(); + keys.forEach( + (alias, key) -> { + if (binding.permitted(keys.keySet()).contains(alias)) { + permitted.put(alias, key); + } + }); + return permitted; + } + + @Override + public String type() { + return "test"; + } + }; + } + + private byte[] randomKey() { + return HexFormat.of() + .parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + } + + private GenericEvent noteBy(PublicKey author) { + return GenericEvent.builder().pubKey(author).kind(1).content("signed in the vault").build(); + } + + private boolean allZero(byte[] key) { + for (byte b : key) { + if (b != 0) { + return false; + } + } + return true; + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/DirectMessageToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/DirectMessageToolsIT.java new file mode 100644 index 00000000..8ef344cb --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/DirectMessageToolsIT.java @@ -0,0 +1,279 @@ +package nostr.mcp.integration; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.social.McpDirectMessageService; +import nostr.mcp.tool.ReadDirectMessagesTool; +import nostr.mcp.tool.SendDirectMessageTool; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sends real gift-wrapped messages between real identities through a real relay. + * + *

The two behaviours this ticket cares about only appear against a live relay: a recipient + * with no relay list genuinely cannot be sent to, and the sender's own archival copy is a second + * outcome that must not be counted as a failed delivery. Both were previously observed here + * rather than reasoned about. + */ +@Testcontainers +class DirectMessageToolsIT { + + private static final int DM_RELAY_LIST_KIND = 10050; + private static final int GIFT_WRAP_KIND = 1059; + private static final String ALIAS = "sender"; + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(8080) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies a message reaches a recipient who has published a relay list, which is the whole + // delivery path: sealing, wrapping, relay-list lookup and publication. + @Test + void aMessageReachesARecipientWithARelayList() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + publishRelayList(sender); + publishRelayList(recipient); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf(sender)) { + CallToolResult sent = send(pool, vault, recipient, "hello " + System.nanoTime()); + + assertFalse(Boolean.TRUE.equals(sent.isError()), textOf(sent)); + assertEquals("Delivered to 1 recipient.", textOf(sent)); + assertEquals(1L, structuredOf(sent).get("delivered")); + } + } + + // Verifies a recipient with no relay list is reported unreachable and named, since NIP-17 + // forbids sending to them and the user must know who missed the message. + @Test + void aRecipientWithNoRelayListIsReportedUnreachable() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity unreachable = Identity.generateRandomIdentity(); + publishRelayList(sender); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf(sender)) { + CallToolResult sent = send(pool, vault, unreachable, "into the void"); + + assertTrue(textOf(sent).contains("Delivered to 0 of 1"), textOf(sent)); + assertTrue(textOf(sent).contains("no relay list"), textOf(sent)); + assertTrue(textOf(sent).contains(unreachable.getPublicKey().toHexString()), textOf(sent)); + } + } + + // Verifies a sender without their own relay list is told about their missing archival copy as + // advice, not as a delivery failure: the recipient did receive the message. + @Test + void theSendersOwnCopyIsNotCountedAsAFailedDelivery() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + publishRelayList(recipient); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf(sender)) { + CallToolResult sent = send(pool, vault, recipient, "one way " + System.nanoTime()); + + assertTrue(textOf(sent).startsWith("Delivered to 1 recipient."), textOf(sent)); + assertTrue(textOf(sent).contains("archival copy"), textOf(sent)); + assertEquals(1L, structuredOf(sent).get("delivered")); + } + } + + // Verifies a sent message can be read back and decrypted by its recipient, proving the + // envelope is genuinely readable rather than merely well-formed. + @Test + void aSentMessageIsReadableByItsRecipient() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + publishRelayList(sender); + publishRelayList(recipient); + String content = "readable " + System.nanoTime(); + + try (RelayPool pool = pool(); + IdentityVault senderVault = vaultOf(sender)) { + send(pool, senderVault, recipient, content); + } + + try (RelayPool pool = pool(); + IdentityVault recipientVault = vaultOf(recipient)) { + CallToolResult read = + new ReadDirectMessagesTool( + new McpDirectMessageService(recipientVault, pool, Set.of(ALIAS)), + recipientVault, + new EventQuery(pool), + QueryLimits.defaults(), + Clock.systemUTC()) + .call(new CallToolRequest("nostr_read_direct_messages", Map.of())); + + assertFalse(Boolean.TRUE.equals(read.isError()), textOf(read)); + assertTrue(read.structuredContent().toString().contains(content), textOf(read)); + } + } + + // Verifies reading is refused unless the identity's owner has enabled it, since decrypting + // correspondence puts it into the conversation and the host's logs. + @Test + void readingIsRefusedUnlessExplicitlyEnabled() throws Exception { + Identity owner = Identity.generateRandomIdentity(); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf(owner)) { + CallToolResult read = + new ReadDirectMessagesTool( + new McpDirectMessageService(vault, pool, Set.of()), + vault, + new EventQuery(pool), + QueryLimits.defaults(), + Clock.systemUTC()) + .call(new CallToolRequest("nostr_read_direct_messages", Map.of())); + + assertTrue(Boolean.TRUE.equals(read.isError()), textOf(read)); + assertTrue(textOf(read).contains("not enabled"), textOf(read)); + } + } + + // Verifies the relay never sees who is talking to whom, which is the guarantee NIP-17 exists + // to provide and the reason NIP-04 is not offered at all. + @Test + void theRelayCannotSeeTheCorrespondents() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Identity recipient = Identity.generateRandomIdentity(); + publishRelayList(sender); + publishRelayList(recipient); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf(sender)) { + send(pool, vault, recipient, "private " + System.nanoTime()); + } + + try (RelayPool pool = pool()) { + List wraps = + new EventQuery(pool) + .run( + nostr.event.filter.EventFilter.builder().kind(GIFT_WRAP_KIND).limit(50).build(), + 50, + Duration.ofSeconds(15)) + .events(); + + assertFalse(wraps.isEmpty(), "no gift wraps were stored"); + assertTrue( + wraps.stream() + .noneMatch( + wrap -> + wrap.getPubKey().toHexString().equals(sender.getPublicKey().toHexString())), + "a gift wrap was signed by the real sender, exposing the correspondents"); + } + } + + private CallToolResult send( + RelayPool pool, IdentityVault vault, Identity recipient, String content) { + return new SendDirectMessageTool( + new McpDirectMessageService(vault, pool, Set.of()), vault) + .call( + new CallToolRequest( + "nostr_send_direct_message", + Map.of( + "recipients", List.of(recipient.getPublicKey().toHexString()), + "content", content))); + } + + /** Publishes the kind-10050 list that says where this key receives private messages. */ + private void publishRelayList(Identity owner) throws Exception { + GenericEvent relayList = + GenericEvent.builder() + .pubKey(owner.getPublicKey()) + .kind(DM_RELAY_LIST_KIND) + .content("") + .createdAt(System.currentTimeMillis() / 1000) + .build(); + relayList.addTag(new nostr.event.tag.GenericTag("relay", List.of(relayUri()))); + relayList.update(); + owner.sign(relayList); + try (RelayPool pool = pool()) { + pool.publish(relayList); + } + } + + private IdentityVault vaultOf(Identity identity) { + byte[] key = HexFormat.of().parseHex(identity.getPrivateKey().toHexString()); + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(ALIAS, key); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), DirectMessageToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(8080); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java new file mode 100644 index 00000000..d7b74a77 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java @@ -0,0 +1,384 @@ +package nostr.mcp.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import org.testcontainers.containers.Network; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.ollama.OllamaContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Drives the tool surface with a real language model. + * + *

Every other test here asks whether the tools work. This asks the different question the + * module actually exists to answer: whether a model can use them. A tool can be + * correct and still unusable, because its name misleads, its description omits the thing the + * model needs to decide, or its schema invites an argument the model cannot supply. None of + * that is visible to a test that calls the tool directly, and all of it decides whether the + * server is any good in the hands of an agent. + * + *

It runs against a local Ollama, using the host's model cache so no model is downloaded. + * Where no cache is present the test skips rather than pulling gigabytes, since a test that + * silently downloads a model on someone's laptop is a test they will disable. + * + *

Tagged {@code model-driven} and excluded from the ordinary build, because it takes minutes + * and depends on a model being present. Run it when changing a tool's name, description or + * schema, since it is the only test that can tell whether a model still understands them: + * + *

{@code mvn verify -pl nostr-java-mcp -Dexcluded.it.groups= -Dgroups=model-driven}
+ * + *

What it does and does not prove. Names and descriptions reinforce each + * other, so these tests still pass if only one of the two is spoiled: gutting + * {@code nostr_subscribe}'s description to "Get events." leaves the name carrying the meaning, + * and the suite stays green. Checked the other way round, with both tools renamed to + * {@code nostr_alpha} and {@code nostr_beta}, the model still chooses correctly from the + * descriptions alone, so the descriptions are doing real work rather than riding on the names. + * Read a pass as "the surface is comprehensible", not as "every word of it is load-bearing". + */ +@Tag("model-driven") +@Testcontainers +class OllamaAgentIT { + + private static final String MODEL = "qwen2.5:7b"; + private static final Path HOST_MODELS = Path.of(System.getProperty("user.home"), ".ollama", "models"); + private static final Duration MODEL_TIMEOUT = Duration.ofMinutes(5); + private static final ObjectMapper JSON = new ObjectMapper(); + + private static final Network NETWORK = Network.newNetwork(); + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(8080) + .withStartupAttempts(5) + .withNetwork(NETWORK) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + @Container + private static final OllamaContainer OLLAMA = + new OllamaContainer(DockerImageName.parse("ollama/ollama:latest")) + // Reuses the models already on this machine. Testcontainers has no preload hook, and + // pulling several gigabytes inside a test run would make it unusable in practice. + .withFileSystemBind(HOST_MODELS.toString(), "/root/.ollama/models") + .withStartupTimeout(Duration.ofMinutes(3)); + + @BeforeAll + static void requireTheModel() { + assumeTrue(Files.isDirectory(HOST_MODELS), "no local Ollama model cache; skipping"); + assumeTrue(availableModels().contains(MODEL), MODEL + " is not pulled locally; skipping"); + } + + // Verifies a model asked a question in plain language picks the right tool unprompted. This is + // the claim a tool surface makes and the one nothing else here tests: the names and + // descriptions have to be good enough that a model reaches for the correct one on its own. + @Test + void aModelChoosesTheRightToolForAPlainLanguageQuestion() throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + + JsonNode call = firstToolCall(ask("Which relays is this Nostr server connected to?", toolsOf(mcp))); + + assertEquals("nostr_list_relays", call.path("function").path("name").asText(), call.toString()); + } + } + + // Verifies the model can carry a tool's answer back into a useful reply, which is the whole + // round trip an agent performs and the reason the results are worded for a reader. + @Test + void aModelCanUseAToolResultToAnswerTheQuestion() throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + List tools = toolsOf(mcp); + + JsonNode call = firstToolCall(ask("Which relays is this Nostr server connected to?", tools)); + CallToolResult result = mcp.callTool(new CallToolRequest(call.path("function").path("name").asText(), Map.of())); + String answer = answerAfterToolResult("Which relays is this server connected to?", tools, call, textOf(result)); + + assertTrue(answer.toLowerCase().contains("relay"), answer); + } + } + + // Verifies the model reaches for the querying tool, not the subscribing one, when asked about + // things that already happened. The two are easy to confuse and the distinction lives entirely + // in their descriptions, so this is really a test of how they are worded. + @Test + void aModelDistinguishesQueryingFromSubscribing() throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + List tools = toolsOf(mcp); + + String past = firstToolCall(ask("What notes have been posted recently? Look at what already exists.", tools)) + .path("function").path("name").asText(); + String future = firstToolCall(ask("Start watching for any new notes that arrive from now on.", tools)) + .path("function").path("name").asText(); + + assertEquals("nostr_query_events", past, "asked about the past, the model chose " + past); + assertEquals("nostr_subscribe", future, "asked to watch, the model chose " + future); + } + } + + // Verifies a model asked to post produces a publish call rather than something destructive. + // Publishing is irreversible, so the surface must not make a neighbouring tool look plausible. + @Test + void aModelAskedToPostChoosesToPublish() throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + + JsonNode call = firstToolCall(ask("Post a note saying hello to Nostr.", toolsOf(mcp))); + String chosen = call.path("function").path("name").asText(); + + assertEquals("nostr_publish_note", chosen, "the model chose " + chosen); + assertTrue(call.path("function").path("arguments").has("content"), call.toString()); + } + } + + // Verifies the confirmation preview reads as "not yet done" to a model, which is the property + // the whole write guard depends on. A model that reads the preview as success would tell its + // user the note is published and never send the token. + @Test + void aModelUnderstandsThatAPreviewHasNotPublishedYet() throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + + CallToolResult preview = + mcp.callTool(new CallToolRequest("nostr_publish_note", Map.of("content", "hello from a test"))); + String verdict = + askPlainly( + "A tool returned this exactly:\n\n" + + textOf(preview) + + "\n\nHas the note been published yet? Answer with the single word YES or NO."); + + assertTrue(verdict.toUpperCase().contains("NO"), "the model read the preview as published: " + verdict); + } + } + + // Verifies a model reads an empty subscription that is still replaying as "not yet", not as + // "there is nothing". That distinction is why the result carries backlogDrained at all, and it + // only matters if a model actually acts on it. + @Test + void aModelUnderstandsAReplayingSubscriptionIsNotAnEmptyOne() throws Exception { + String verdict = + askPlainly( + "A Nostr tool returned this exactly:\n\n" + + "Nothing yet: the relays are still replaying their stored events.\n\n" + + "Does this mean there are definitely no matching events? Answer YES or NO."); + + assertTrue(verdict.toUpperCase().contains("NO"), "the model treated a replaying read as empty: " + verdict); + } + + // Verifies the model reaches the right tool across the whole surface, not just the handful a + // few hand-written cases happen to cover. Each request is phrased as a user would put it, with + // every one of the twenty-two tools offered, so a tool whose name or description does not + // distinguish it from its neighbours shows up here. + @ParameterizedTest(name = "\"{0}\" should reach {1}") + @MethodSource("requestsAndTheToolTheyNeed") + void aModelReachesTheRightToolAcrossTheSurface(String request, String expectedTool) throws Exception { + try (McpSyncClient mcp = launchServer()) { + mcp.initialize(); + + JsonNode call = firstToolCall(ask(request, toolsOf(mcp))); + + assertEquals(expectedTool, call.path("function").path("name").asText(), call.toString()); + } + } + + /** + * One plain-language request per tool an agent would plausibly be asked to reach. + * + *

Covers the surface rather than a sample, because the risk being tested is that two tools + * read alike to a model, and that only shows when both are on offer. The identity lifecycle + * tools are included deliberately: they neighbour each other closely, and choosing "remove" + * where "rename" was meant is not recoverable. + */ + private static Stream requestsAndTheToolTheyNeed() { + return Stream.of( + arguments("Which relays is this server connected to?", "nostr_list_relays"), + arguments("What is the name and description of the relay wss://relay.example?", "nostr_relay_info"), + arguments("Which identities can this server sign as?", "nostr_list_identities"), + arguments("Find notes posted in the last day.", "nostr_query_events"), + arguments("Look up the profile for npub1abc, what is their bio?", "nostr_get_profile"), + arguments("Show me the replies to note1xyz so I can read the conversation.", "nostr_fetch_thread"), + arguments("Who do I follow?", "nostr_get_contacts"), + // Deliberately not "mentioning me": that needs the caller's own key, and a model that + // asks which identity to watch rather than guessing is behaving correctly. Testing tool + // selection means not conflating it with a missing argument the model is right to + // question. + arguments("Start watching for any new notes of kind 1 as they arrive.", "nostr_subscribe"), + arguments("Any new events in my watch with id sub-1 yet?", "nostr_read_subscription"), + arguments("What am I currently watching?", "nostr_list_subscriptions"), + arguments("Stop watching subscription sub-1.", "nostr_unsubscribe"), + arguments("Post a note saying hello to Nostr.", "nostr_publish_note"), + arguments("Change my display name to Alice and my bio to 'testing'.", "nostr_update_profile"), + arguments("Send a private encrypted message to npub1abc saying hi.", "nostr_send_direct_message"), + arguments("Do I have any private messages?", "nostr_read_direct_messages"), + arguments("Make me a brand new Nostr account called project-bot.", "nostr_create_identity"), + arguments("I have an existing Nostr key saved in the file /tmp/key.txt. Add it to this" + + " server under the alias 'adopted'.", "nostr_import_identity"), + arguments("Rename my identity 'old-name' to 'new-name'.", "nostr_rename_identity"), + arguments("From now on sign as 'project-bot' by default.", "nostr_set_default_identity"), + arguments("Save an encrypted backup of my key 'personal' to /tmp/backup.p12.", "nostr_export_identity_backup")); + } + + private List toolsOf(McpSyncClient mcp) { + return mcp.listTools().tools(); + } + + /** Asks the model a question with the real tool surface attached. */ + private JsonNode ask(String question, List tools) throws Exception { + ObjectNode request = JSON.createObjectNode(); + request.put("model", MODEL); + request.put("stream", false); + ArrayNode messages = request.putArray("messages"); + messages.addObject().put("role", "user").put("content", question); + request.set("tools", asOllamaTools(tools)); + return chat(request); + } + + /** Asks the model to reason about a tool's output, with no tools attached. */ + private String askPlainly(String question) throws Exception { + ObjectNode request = JSON.createObjectNode(); + request.put("model", MODEL); + request.put("stream", false); + request.putArray("messages").addObject().put("role", "user").put("content", question); + return chat(request).path("content").asText(); + } + + private String answerAfterToolResult( + String question, List tools, JsonNode call, String toolOutput) throws Exception { + ObjectNode request = JSON.createObjectNode(); + request.put("model", MODEL); + request.put("stream", false); + ArrayNode messages = request.putArray("messages"); + messages.addObject().put("role", "user").put("content", question); + ObjectNode assistant = messages.addObject(); + assistant.put("role", "assistant").put("content", ""); + assistant.putArray("tool_calls").add(call); + messages.addObject().put("role", "tool").put("content", toolOutput); + request.set("tools", asOllamaTools(tools)); + return chat(request).path("content").asText(); + } + + /** + * Translates the MCP tool surface into Ollama's function-calling shape. + * + *

Deliberately a direct mapping of what the server advertises: the point is to test the + * real names, descriptions and schemas, so anything reworded here would be testing this + * method instead of the server. + */ + private ArrayNode asOllamaTools(List tools) { + ArrayNode array = JSON.createArrayNode(); + for (Tool tool : tools) { + ObjectNode entry = array.addObject(); + entry.put("type", "function"); + ObjectNode function = entry.putObject("function"); + function.put("name", tool.name()); + function.put("description", tool.description()); + function.set("parameters", JSON.valueToTree(tool.inputSchema())); + } + return array; + } + + private JsonNode chat(ObjectNode request) throws Exception { + HttpResponse response = + HttpClient.newBuilder() + .connectTimeout(MODEL_TIMEOUT) + .build() + .send( + HttpRequest.newBuilder(URI.create(OLLAMA.getEndpoint() + "/api/chat")) + .timeout(MODEL_TIMEOUT) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(request.toString())) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), response.body()); + return JSON.readTree(response.body()).path("message"); + } + + private JsonNode firstToolCall(JsonNode message) { + JsonNode calls = message.path("tool_calls"); + assertTrue(calls.isArray() && !calls.isEmpty(), + "the model called no tool; it replied: " + message.path("content").asText()); + return calls.get(0); + } + + private static List availableModels() { + try { + HttpResponse response = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(URI.create(OLLAMA.getEndpoint() + "/api/tags")) + .timeout(Duration.ofSeconds(30)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + List names = new ArrayList<>(); + JSON.readTree(response.body()).path("models").forEach(model -> names.add(model.path("name").asText())); + return names; + } catch (Exception e) { + return List.of(); + } + } + + /** Launches the server exactly as an MCP host does, pointed at the relay. */ + private McpSyncClient launchServer() { + ServerParameters parameters = + ServerParameters.builder("java") + .args( + "-Dnostr.mcp.relays.read=ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(8080), + "-cp", + System.getProperty("java.class.path"), + nostr.mcp.NostrMcpApplication.class.getName()) + .build(); + return McpClient.sync(new StdioClientTransport(parameters, McpJsonDefaults.getMapper())) + .requestTimeout(Duration.ofSeconds(60)) + .build(); + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/PublishToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/PublishToolsIT.java new file mode 100644 index 00000000..1f55560a --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/PublishToolsIT.java @@ -0,0 +1,234 @@ +package nostr.mcp.integration; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.tool.PublishNoteTool; +import nostr.mcp.tool.QueryEventsTool; +import nostr.mcp.tool.UpdateProfileTool; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Publishes to a real relay, through the guard, and reads the result back. + * + *

The claim worth testing is not that the code signs an event but that a relay accepts it and + * serves it again. Signature encoding, event id derivation and the relay's own validation are all + * outside a unit test, and all of them are places where a plausible-looking event is silently + * rejected. + */ +@Testcontainers +class PublishToolsIT { + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(8080) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies the two-step confirmation actually publishes: the preview stores nothing, and the + // confirmed call produces an event the relay serves back. + @Test + void aConfirmedNoteReachesTheRelayAndCanBeReadBack() throws Exception { + String content = "confirmed note " + System.nanoTime(); + + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + PublishNoteTool tool = new PublishNoteTool(guard(pool, vault, WritePolicy.CONFIRM)); + + CallToolResult preview = + tool.call(new CallToolRequest("nostr_publish_note", Map.of("content", content))); + assertTrue(textOf(preview).contains("Nothing has been published yet"), textOf(preview)); + assertEquals(0, storedNotesBy(pool, vault), "the preview published something"); + + String token = String.valueOf(structuredOf(preview).get("confirmationToken")); + CallToolResult published = + tool.call( + new CallToolRequest( + "nostr_publish_note", Map.of("content", content, "confirmationToken", token))); + + assertFalse(Boolean.TRUE.equals(published.isError()), textOf(published)); + assertTrue(textOf(published).startsWith("Published "), textOf(published)); + assertEquals(1, storedNotesBy(pool, vault)); + } + } + + // Verifies an unconfirmed note never reaches the relay, which is the entire point of the + // confirming policy: a hallucinated post is a no-op. + @Test + void anUnconfirmedNoteNeverReachesTheRelay() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + PublishNoteTool tool = new PublishNoteTool(guard(pool, vault, WritePolicy.CONFIRM)); + + tool.call(new CallToolRequest("nostr_publish_note", Map.of("content", "never sent"))); + tool.call(new CallToolRequest("nostr_publish_note", Map.of("content", "also never sent"))); + + assertEquals(0, storedNotesBy(pool, vault)); + } + } + + // Verifies the allowing policy publishes on the first call, for trusted automation. + @Test + void allowingPublishesOnTheFirstCall() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + PublishNoteTool tool = new PublishNoteTool(guard(pool, vault, WritePolicy.ALLOW)); + + CallToolResult result = + tool.call( + new CallToolRequest( + "nostr_publish_note", Map.of("content", "direct " + System.nanoTime()))); + + assertTrue(textOf(result).startsWith("Published "), textOf(result)); + assertEquals(1, storedNotesBy(pool, vault)); + } + } + + // Verifies a published profile is readable as a profile, proving kind-0 encoding is right + // rather than merely well-formed. + @Test + void aPublishedProfileIsReadableAsAProfile() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + new UpdateProfileTool(guard(pool, vault, WritePolicy.ALLOW)) + .call( + new CallToolRequest( + "nostr_update_profile", Map.of("name", "alice", "about", "integration"))); + + CallToolResult profile = + new nostr.mcp.tool.GetProfileTool( + new EventQuery(pool), + new nostr.mcp.directory.Nip05Resolver(new nostr.mcp.directory.WellKnownJson()), + QueryLimits.defaults()) + .call( + new CallToolRequest( + "nostr_get_profile", + Map.of("pubkey", vault.publicKeyOf("personal").toHexString()))); + + assertEquals("alice: integration", textOf(profile)); + } + } + + // Verifies the rate limit holds against a real relay, so a runaway agent is stopped before the + // events become permanent rather than after. + @Test + void theRateLimitStopsPublishingBeforeTheRelaySeesIt() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + PublishNoteTool tool = + new PublishNoteTool( + new WriteGuard( + pool, + vault, + WritePolicy.ALLOW, + new RateLimit(2, Duration.ofMinutes(1), Clock.systemUTC()))); + + for (int index = 0; index < 2; index++) { + tool.call(new CallToolRequest("nostr_publish_note", Map.of("content", "rate " + index))); + } + CallToolResult limited = + tool.call(new CallToolRequest("nostr_publish_note", Map.of("content", "over the limit"))); + + assertTrue(Boolean.TRUE.equals(limited.isError()), textOf(limited)); + assertTrue(textOf(limited).startsWith("WRITE_FORBIDDEN"), textOf(limited)); + assertEquals(2, storedNotesBy(pool, vault)); + } + } + + private int storedNotesBy(RelayPool pool, IdentityVault vault) { + CallToolResult found = + new QueryEventsTool(new EventQuery(pool), QueryLimits.defaults(), Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of( + "authors", List.of(vault.publicKeyOf("personal").toHexString()), + "kinds", List.of(1)))); + return (int) structuredOf(found).get("count"); + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private WriteGuard guard(RelayPool pool, IdentityVault vault, WritePolicy policy) { + return new WriteGuard( + pool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())); + } + + private IdentityVault vaultOf(String alias) { + byte[] key = + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(alias, key); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), PublishToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(8080); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/ReadToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/ReadToolsIT.java new file mode 100644 index 00000000..4de4a746 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/ReadToolsIT.java @@ -0,0 +1,274 @@ +package nostr.mcp.integration; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.directory.Nip05Resolver; +import nostr.mcp.directory.WellKnownJson; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.tool.GetProfileTool; +import nostr.mcp.tool.QueryEventsTool; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs the read tools against a real relay. + * + *

The read path is almost entirely about behaviour a fake cannot reproduce: a query terminates + * on an end-of-stored-events signal the relay sends when it chooses, events arrive on transport + * threads, and the relay decides what matches a filter. A stubbed pool would prove only that the + * tools call the methods this test's author expected them to. + */ +@Testcontainers +class ReadToolsIT { + + private static final DockerImageName RELAY_IMAGE = + DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13"); + private static final int RELAY_PORT = 8080; + private static final int PROFILE_KIND = 0; + private static final int TEXT_NOTE_KIND = 1; + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(RELAY_IMAGE) + .withExposedPorts(RELAY_PORT) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies a published note is found by a query filtered on its author, which is the read + // path working end to end: filter encoding, REQ, collection, and termination on EOSE. + @Test + void aPublishedNoteIsFoundByAuthor() throws Exception { + Identity author = Identity.generateRandomIdentity(); + String content = "read tools " + System.nanoTime(); + publish(note(author, content)); + + try (RelayPool pool = pool()) { + CallToolResult result = + queryTool(pool) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(author.getPublicKey().toHexString())))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).contains("Found 1 event"), textOf(result)); + } + } + + // Verifies an npub is accepted where hex is, since that is the form a user pastes. + @Test + void anNpubAuthorWorksAsWellAsHex() throws Exception { + Identity author = Identity.generateRandomIdentity(); + publish(note(author, "npub query " + System.nanoTime())); + + try (RelayPool pool = pool()) { + CallToolResult result = + queryTool(pool) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(author.getPublicKey().toBech32String())))); + + assertTrue(textOf(result).contains("Found 1 event"), textOf(result)); + } + } + + // Verifies a query that matches nothing says so plainly, rather than hanging until the + // timeout, which is what a wrong termination condition would look like. + @Test + void aQueryMatchingNothingReturnsPromptly() throws Exception { + Identity stranger = Identity.generateRandomIdentity(); + + try (RelayPool pool = pool()) { + long startedAt = System.currentTimeMillis(); + CallToolResult result = + queryTool(pool) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(stranger.getPublicKey().toHexString())))); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals("No events matched.", textOf(result)); + assertTrue(elapsed < 10_000, "the query took " + elapsed + "ms, so it waited for a timeout"); + } + } + + // Verifies the configured limit actually bounds the answer, and that the agent is told the + // result was cut short rather than being left to assume it was complete. + @Test + void theEventLimitBoundsTheAnswerAndSaysSo() throws Exception { + Identity author = Identity.generateRandomIdentity(); + for (int index = 0; index < 5; index++) { + publish(note(author, "limit " + index + " " + System.nanoTime())); + } + + try (RelayPool pool = pool()) { + CallToolResult result = + new QueryEventsTool(new EventQuery(pool), new QueryLimits(2, Duration.ofSeconds(15)), Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(author.getPublicKey().toHexString())))); + + assertTrue(textOf(result).contains("Found 2 events"), textOf(result)); + assertTrue(textOf(result).contains("limit was reached"), textOf(result)); + } + } + + // Verifies a profile is fetched and its JSON content decoded, which is the part of the read + // path a model most often gets wrong when doing it itself. + @Test + void aProfileIsFetchedAndDecoded() throws Exception { + Identity subject = Identity.generateRandomIdentity(); + publish(profile(subject, "{\"name\":\"alice\",\"about\":\"testing\"}")); + + try (RelayPool pool = pool()) { + CallToolResult result = + profileTool(pool) + .call( + new CallToolRequest( + "nostr_get_profile", + Map.of("pubkey", subject.getPublicKey().toHexString()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals("alice: testing", textOf(result)); + } + } + + // Verifies a key with no profile is an ordinary answer rather than an error, since most keys + // have never published one. + @Test + void aKeyWithNoProfileIsAnAnswerNotAnError() throws Exception { + Identity stranger = Identity.generateRandomIdentity(); + + try (RelayPool pool = pool()) { + CallToolResult result = + profileTool(pool) + .call( + new CallToolRequest( + "nostr_get_profile", + Map.of("pubkey", stranger.getPublicKey().toHexString()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).contains("No profile"), textOf(result)); + } + } + + // Verifies a malformed profile is reported rather than failing the call, since anyone can + // publish anything as kind 0 and that is not the caller's fault. + @Test + void aMalformedProfileDoesNotFailTheCall() throws Exception { + Identity subject = Identity.generateRandomIdentity(); + publish(profile(subject, "this is not json")); + + try (RelayPool pool = pool()) { + CallToolResult result = + profileTool(pool) + .call( + new CallToolRequest( + "nostr_get_profile", + Map.of("pubkey", subject.getPublicKey().toHexString()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + } + } + + // Verifies a bad identifier is refused with a code the agent can act on, before any relay is + // troubled with it. + @Test + void aBadIdentifierIsRefusedWithACode() throws Exception { + try (RelayPool pool = pool()) { + CallToolResult result = + profileTool(pool).call(new CallToolRequest("nostr_get_profile", Map.of("pubkey", "wat"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).startsWith("INVALID_ARGUMENT"), textOf(result)); + } + } + + private QueryEventsTool queryTool(RelayPool pool) { + return new QueryEventsTool(new EventQuery(pool), QueryLimits.defaults(), Clock.systemUTC()); + } + + private GetProfileTool profileTool(RelayPool pool) { + return new GetProfileTool( + new EventQuery(pool), new Nip05Resolver(new WellKnownJson()), QueryLimits.defaults()); + } + + private GenericEvent note(Identity author, String content) { + return signed(author, TEXT_NOTE_KIND, content); + } + + private GenericEvent profile(Identity author, String content) { + return signed(author, PROFILE_KIND, content); + } + + private GenericEvent signed(Identity author, int kind, String content) { + GenericEvent event = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(kind) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.update(); + author.sign(event); + return event; + } + + private void publish(GenericEvent event) throws Exception { + try (RelayPool pool = pool()) { + pool.publish(event); + } + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), ReadToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(RELAY_PORT); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/SubscriptionToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/SubscriptionToolsIT.java new file mode 100644 index 00000000..347e1acb --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/SubscriptionToolsIT.java @@ -0,0 +1,237 @@ +package nostr.mcp.integration; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.subscription.SubscriptionLimits; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.tool.ListSubscriptionsTool; +import nostr.mcp.tool.ReadSubscriptionTool; +import nostr.mcp.tool.SubscribeTool; +import nostr.mcp.tool.UnsubscribeTool; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Watches a real relay for events that had not happened when the subscription opened. + * + *

This is the behaviour a fake cannot demonstrate: the relay decides when to send its + * end-of-backlog signal, events stream in on transport threads afterwards, and a note published + * later has to find its way to a subscription opened before it existed. + */ +@Testcontainers +class SubscriptionToolsIT { + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(8080) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies a note published after the subscription opened is delivered to it, which is the + // whole reason subscriptions exist rather than only queries. + @Test + void aNotePublishedAfterSubscribingArrives() throws Exception { + Identity author = Identity.generateRandomIdentity(); + String content = "live " + System.nanoTime(); + + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + String subscriptionId = subscribeTo(registry, author); + + publish(note(author, content)); + + await() + .atMost(15, TimeUnit.SECONDS) + .until(() -> registry.require(subscriptionId).depth() > 0); + CallToolResult read = read(registry, subscriptionId); + assertTrue(textOf(read).contains("Received 1 event"), textOf(read)); + } + } + + // Verifies reading drains, so a second read returns nothing rather than the same events again. + @Test + void readingTwiceDoesNotRepeatEvents() throws Exception { + Identity author = Identity.generateRandomIdentity(); + + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + String subscriptionId = subscribeTo(registry, author); + publish(note(author, "once " + System.nanoTime())); + await().atMost(15, TimeUnit.SECONDS).until(() -> registry.require(subscriptionId).depth() > 0); + + read(registry, subscriptionId); + CallToolResult second = read(registry, subscriptionId); + + assertEquals(0, structuredOf(second).get("count")); + } + } + + // Verifies an empty read before the backlog drains is distinguishable from an empty feed, so + // an agent does not report "no mentions" while the relay is still replaying. + @Test + void anEmptyReadSaysWhetherTheBacklogHasDrained() throws Exception { + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + String subscriptionId = subscribeTo(registry, Identity.generateRandomIdentity()); + + await() + .atMost(15, TimeUnit.SECONDS) + .until(() -> registry.require(subscriptionId).backlogDrained()); + + assertEquals("Nothing new since the last read.", textOf(read(registry, subscriptionId))); + } + } + + // Verifies a full buffer tells the agent it missed events, since a silent gap would be + // summarised as though it were the whole feed. + @Test + void anOverflowingBufferReportsWhatItDropped() throws Exception { + Identity author = Identity.generateRandomIdentity(); + + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, new SubscriptionLimits(10, 2, Duration.ofHours(1)))) { + String subscriptionId = subscribeTo(registry, author); + + for (int index = 0; index < 5; index++) { + publish(note(author, "flood " + index + " " + System.nanoTime())); + } + + await() + .atMost(20, TimeUnit.SECONDS) + .until(() -> registry.require(subscriptionId).droppedCount() > 0); + assertTrue(textOf(read(registry, subscriptionId)).contains("dropped"), "the gap was not reported"); + } + } + + // Verifies listing reports what is open and how deep it is, which is how an agent resuming a + // conversation discovers what it is already watching. + @Test + void listingReportsTheOpenSubscriptions() throws Exception { + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + String subscriptionId = subscribeTo(registry, Identity.generateRandomIdentity()); + + CallToolResult listed = + new ListSubscriptionsTool(registry) + .call(new CallToolRequest("nostr_list_subscriptions", Map.of())); + + assertTrue(textOf(listed).contains(subscriptionId), textOf(listed)); + } + } + + // Verifies unsubscribing closes it, so a later read reports the id as unknown rather than + // quietly returning nothing forever. + @Test + void unsubscribingClosesIt() throws Exception { + try (RelayPool pool = pool(); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + String subscriptionId = subscribeTo(registry, Identity.generateRandomIdentity()); + + new UnsubscribeTool(registry) + .call(new CallToolRequest("nostr_unsubscribe", Map.of("subscriptionId", subscriptionId))); + + CallToolResult read = read(registry, subscriptionId); + assertTrue(Boolean.TRUE.equals(read.isError()), textOf(read)); + assertTrue(textOf(read).startsWith("SUBSCRIPTION_UNKNOWN"), textOf(read)); + } + } + + private String subscribeTo(SubscriptionRegistry registry, Identity author) { + CallToolResult opened = + new SubscribeTool(registry, Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_subscribe", + Map.of( + "authors", List.of(author.getPublicKey().toHexString()), + "kinds", List.of(1)))); + assertFalse(Boolean.TRUE.equals(opened.isError()), textOf(opened)); + return String.valueOf(structuredOf(opened).get("subscriptionId")); + } + + private CallToolResult read(SubscriptionRegistry registry, String subscriptionId) { + return new ReadSubscriptionTool(registry) + .call( + new CallToolRequest( + "nostr_read_subscription", Map.of("subscriptionId", subscriptionId))); + } + + private SubscriptionRegistry registryOf(RelayPool pool, SubscriptionLimits limits) { + return new SubscriptionRegistry(pool, limits, Clock.systemUTC(), subscriptionId -> {}); + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private GenericEvent note(Identity author, String content) { + GenericEvent event = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.update(); + author.sign(event); + return event; + } + + private void publish(GenericEvent event) throws Exception { + try (RelayPool pool = pool()) { + pool.publish(event); + } + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), SubscriptionToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(8080); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/UntestedToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/UntestedToolsIT.java new file mode 100644 index 00000000..e43eedd1 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/UntestedToolsIT.java @@ -0,0 +1,641 @@ +package nostr.mcp.integration; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.directory.WellKnownJson; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentityStore; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.identity.KeystoreException; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.tool.CreateIdentityTool; +import nostr.mcp.tool.ExportIdentityBackupTool; +import nostr.mcp.tool.FetchThreadTool; +import nostr.mcp.tool.ImportIdentityTool; +import nostr.mcp.tool.PublishEventTool; +import nostr.mcp.tool.RelayInfoTool; +import nostr.mcp.tool.RenameIdentityTool; +import nostr.mcp.tool.SetDefaultIdentityTool; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the tools that only ever had their registration checked. + * + *

Eight of the twenty-two were never called by anything: a coverage measurement said so, and + * "it appears in the golden tool list" is a guarantee that the class compiles, not that it works. + * These are the calls that were missing. + */ +@Testcontainers +class UntestedToolsIT { + + private static final int RELAY_PORT = 8080; + + @TempDir Path directory; + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(RELAY_PORT) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + // Verifies the NIP-11 tool reads a real relay's own description of itself, which is how an + // agent learns a relay's rules without breaking one. + @Test + void relayInfoReadsTheRelaysOwnDocument() { + try (RelayPool pool = pool()) { + CallToolResult result = + new RelayInfoTool(directoryOf(), new WellKnownJson()) + .call(new CallToolRequest("nostr_relay_info", Map.of("relay", relayUri()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(structuredOf(result).containsKey("supported_nips"), structuredOf(result).toString()); + } + } + + // Verifies an unknown relay name is refused by naming the ones that exist, rather than + // failing with something the agent cannot act on. + @Test + void relayInfoRefusesAnUnknownRelayByName() { + try (RelayPool pool = pool()) { + CallToolResult result = + new RelayInfoTool(directoryOf(), new WellKnownJson()) + .call(new CallToolRequest("nostr_relay_info", Map.of("relay", "not-a-relay"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).startsWith("INVALID_ARGUMENT"), textOf(result)); + } + } + + // Verifies a thread is assembled from a note and the replies pointing at it, which is two + // queries an agent would otherwise have to know how to compose itself. + @Test + void fetchThreadFindsANoteAndItsReplies() throws Exception { + Identity author = Identity.generateRandomIdentity(); + GenericEvent root = note(author, "the opening note " + System.nanoTime()); + publish(root); + publish(replyTo(author, root, "a reply")); + publish(replyTo(author, root, "another reply")); + + try (RelayPool pool = pool()) { + CallToolResult result = + new FetchThreadTool(new EventQuery(pool), QueryLimits.defaults()) + .call(new CallToolRequest("nostr_fetch_thread", Map.of("eventId", root.getId()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals(2, structuredOf(result).get("replyCount"), textOf(result)); + } + } + + // Verifies an unknown event id is an answer rather than an error, since asking about a note + // no configured relay carries is an ordinary thing to do. + @Test + void fetchThreadReportsAnUnknownNoteAsAnAnswer() { + try (RelayPool pool = pool()) { + CallToolResult result = + new FetchThreadTool(new EventQuery(pool), QueryLimits.defaults()) + .call( + new CallToolRequest( + "nostr_fetch_thread", + Map.of("eventId", "a".repeat(64)))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals(false, structuredOf(result).get("found"), textOf(result)); + } + } + + // Verifies the escape hatch publishes a kind nothing else wraps, with its tags intact, which + // is what keeps the server useful for NIPs it has never heard of. + @Test + void publishEventSendsAnArbitraryKindWithItsTags() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + CallToolResult result = + new PublishEventTool(guard(pool, vault)) + .call( + new CallToolRequest( + "nostr_publish_event", + Map.of( + "kind", 30023, + "content", "a long-form article", + "tags", List.of(List.of("d", "my-article"), List.of("title", "Hello"))))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).startsWith("Published "), textOf(result)); + + CallToolResult found = + new nostr.mcp.tool.QueryEventsTool(new EventQuery(pool), QueryLimits.defaults(), Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(vault.publicKeyOf("personal").toHexString()), "kinds", List.of(30023)))); + + assertEquals(1, structuredOf(found).get("count"), textOf(found)); + } + } + + // Verifies a tag with no name is refused, since publishing a malformed tag is not something + // the caller can undo. + @Test + void publishEventRefusesAMalformedTag() { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + CallToolResult result = + new PublishEventTool(guard(pool, vault)) + .call( + new CallToolRequest( + "nostr_publish_event", + Map.of("kind", 1, "content", "x", "tags", List.of(List.of())))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + } + } + + // Verifies creating an identity through the tool yields one the server can then sign with, + // which is the whole point of letting an agent make a throwaway account. + @Test + void createIdentityProducesAUsableSigningIdentity() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + + CallToolResult created = + new CreateIdentityTool(lifecycle) + .call(new CallToolRequest("nostr_create_identity", Map.of("alias", "throwaway"))); + + assertFalse(Boolean.TRUE.equals(created.isError()), textOf(created)); + assertTrue(textOf(created).contains("npub1"), textOf(created)); + + CallToolResult published = + new nostr.mcp.tool.PublishNoteTool(guard(pool, vault)) + .call(new CallToolRequest("nostr_publish_note", Map.of("content", "from a new key"))); + + assertTrue(textOf(published).startsWith("Published "), textOf(published)); + } + } + + // Verifies an unusable alias is refused, since aliases appear in resource URIs. + @Test + void createIdentityRefusesAnAliasThatWouldBreakAUri() { + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new CreateIdentityTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call(new CallToolRequest("nostr_create_identity", Map.of("alias", "has spaces"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).startsWith("INVALID_ARGUMENT"), textOf(result)); + } + } + + // Verifies importing reads a key the server can see and never takes one as an argument, + // which is the property that keeps key material out of the model's context. + @Test + void importIdentityReadsAKeyFromAFileRatherThanAnArgument() throws Exception { + Identity existing = Identity.generateRandomIdentity(); + Path keyFile = directory.resolve("imported.key"); + Files.writeString(keyFile, existing.getPrivateKey().toHexString()); + + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new ImportIdentityTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call( + new CallToolRequest( + "nostr_import_identity", + Map.of("alias", "adopted", "source", "file:" + keyFile))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals( + existing.getPublicKey().toHexString(), + vault.publicKeyOf("adopted").toHexString(), + "the imported key is not the one in the file"); + } + } + + // Verifies a key pasted as the source is refused with advice, since by then it is already in + // the conversation. + @Test + void importIdentityRefusesKeyMaterialPastedAsTheSource() { + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new ImportIdentityTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call( + new CallToolRequest( + "nostr_import_identity", + Map.of( + "alias", "leaked", + "source", Identity.generateRandomIdentity().getPrivateKey().toBech32String()))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).contains("compromised"), textOf(result)); + } + } + + // Verifies the shred option really removes the file, so an imported key does not sit on disk + // in plaintext afterwards. + @Test + void importIdentityCanShredTheSourceFile() throws Exception { + Path keyFile = directory.resolve("shred-me.key"); + Files.writeString(keyFile, Identity.generateRandomIdentity().getPrivateKey().toHexString()); + + try (IdentityVault vault = emptyVault()) { + new ImportIdentityTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call( + new CallToolRequest( + "nostr_import_identity", + Map.of("alias", "adopted", "source", "file:" + keyFile, "shredSource", true))); + + assertFalse(Files.exists(keyFile), "the key file survived the import"); + } + } + + // Verifies renaming keeps the key, so the account is untouched and only the local label moves. + @Test + void renameIdentityKeepsTheSameKey() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + String publicKey = lifecycle.create("before").publicKey(); + + CallToolResult result = + new RenameIdentityTool(lifecycle) + .call( + new CallToolRequest( + "nostr_rename_identity", Map.of("alias", "before", "newAlias", "after"))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals(publicKey, vault.publicKeyOf("after").toHexString()); + assertTrue(vault.find("before").isEmpty(), "the old alias still resolves"); + } + } + + // Verifies renaming something absent is reported rather than silently accepted. + @Test + void renameIdentityReportsAnUnknownAlias() { + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new RenameIdentityTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call( + new CallToolRequest( + "nostr_rename_identity", Map.of("alias", "nobody", "newAlias", "somebody"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + } + } + + // Verifies changing the default changes who signs. The first identity created becomes the + // default on its own, so what this tool is really for is moving that choice afterwards, and + // the check that matters is which key the next note is actually signed with. + @Test + void setDefaultIdentityChangesWhoSigns() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + lifecycle.create("first"); + lifecycle.create("second"); + assertEquals("first", vault.defaultAlias().orElseThrow(), "the first identity should default"); + + CallToolResult switched = + new SetDefaultIdentityTool(vault) + .call(new CallToolRequest("nostr_set_default_identity", Map.of("alias", "second"))); + assertFalse(Boolean.TRUE.equals(switched.isError()), textOf(switched)); + + new nostr.mcp.tool.PublishNoteTool(guard(pool, vault)) + .call(new CallToolRequest("nostr_publish_note", Map.of("content", "signed by the new default"))); + + CallToolResult bySecond = + new nostr.mcp.tool.QueryEventsTool(new EventQuery(pool), QueryLimits.defaults(), Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_query_events", + Map.of("authors", List.of(vault.publicKeyOf("second").toHexString()), "kinds", List.of(1)))); + + assertEquals(1, structuredOf(bySecond).get("count"), "the note was not signed by the new default"); + } + } + + // Verifies choosing a default that does not exist is refused, rather than leaving the server + // pointing at nothing. + @Test + void setDefaultIdentityRefusesAnUnknownAlias() { + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new SetDefaultIdentityTool(vault) + .call(new CallToolRequest("nostr_set_default_identity", Map.of("alias", "nobody"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(textOf(result).startsWith("IDENTITY_UNKNOWN"), textOf(result)); + } + } + + // Verifies a backup is written to the path given and the result names the path rather than the + // key, which is what makes taking one safe from inside a conversation. + @Test + void exportBackupWritesAFileAndReturnsOnlyItsPath() { + try (IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + lifecycle.create("personal"); + Path backup = directory.resolve("personal.p12"); + + CallToolResult result = + new ExportIdentityBackupTool(lifecycle) + .call( + new CallToolRequest( + "nostr_export_identity_backup", + Map.of("alias", "personal", "path", backup.toString(), "passphrase", "secret"))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(Files.exists(backup), "no backup file was written"); + assertTrue(textOf(result).contains(backup.toString()), textOf(result)); + assertFalse(textOf(result).contains("nsec"), "the backup tool disclosed key material"); + } + } + + // Verifies backing up something absent is reported rather than writing an empty file. + @Test + void exportBackupReportsAnUnknownIdentity() { + try (IdentityVault vault = emptyVault()) { + CallToolResult result = + new ExportIdentityBackupTool(new IdentityLifecycle(vault, new InMemoryStore())) + .call( + new CallToolRequest( + "nostr_export_identity_backup", + Map.of( + "alias", "nobody", + "path", directory.resolve("nobody.p12").toString(), + "passphrase", "secret"))); + + assertTrue(Boolean.TRUE.equals(result.isError()), textOf(result)); + } + } + + // Verifies a follow list published to a relay is read back with each contact's relay hint and + // petname intact, since those are how a client finds someone it has never seen and shows a + // readable name. Reading only the key would lose both. + @Test + void getContactsReadsAPublishedFollowListFromTheRelay() throws Exception { + Identity follower = Identity.generateRandomIdentity(); + Identity followed = Identity.generateRandomIdentity(); + publish(followList(follower, followed)); + + try (RelayPool pool = pool(); + IdentityVault vault = emptyVault()) { + CallToolResult result = + new nostr.mcp.tool.GetContactsTool(new EventQuery(pool), vault, QueryLimits.defaults()) + .call( + new CallToolRequest( + "nostr_get_contacts", + Map.of("pubkey", follower.getPublicKey().toHexString()))); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertEquals(1, structuredOf(result).get("count"), textOf(result)); + String described = structuredOf(result).get("contacts").toString(); + assertTrue(described.contains(followed.getPublicKey().toHexString()), described); + assertTrue(described.contains("alice"), "the petname was lost: " + described); + assertTrue(described.contains(relayUri()), "the relay hint was lost: " + described); + } + } + + // Verifies the identities tool reports what the running server can sign with, alongside a + // relay, which is how an agent learns who it is before doing anything. + @Test + void listIdentitiesReportsWhatTheServerCanSignWith() { + try (RelayPool pool = pool(); + IdentityVault vault = vaultOf("personal")) { + CallToolResult result = + new nostr.mcp.tool.ListIdentitiesTool(vault) + .call(new CallToolRequest("nostr_list_identities", Map.of())); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(structuredOf(result).get("identities").toString().contains("personal"), textOf(result)); + assertFalse(textOf(result).contains("nsec"), "the identities tool disclosed key material"); + } + } + + // Verifies removing an identity really stops it signing: the note published before removal is + // on the relay, and the attempt afterwards fails rather than quietly using another key. + @Test + void removeIdentityStopsThatKeySigning() throws Exception { + try (RelayPool pool = pool(); + IdentityVault vault = emptyVault()) { + IdentityLifecycle lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + lifecycle.create("doomed"); + String publicKey = vault.publicKeyOf("doomed").toHexString(); + + new nostr.mcp.tool.PublishNoteTool(guard(pool, vault)) + .call(new CallToolRequest("nostr_publish_note", Map.of("content", "before removal"))); + + CallToolResult removed = + new nostr.mcp.tool.RemoveIdentityTool(lifecycle, vault, IdentityPolicy.ALLOW) + .call( + new CallToolRequest( + "nostr_remove_identity", + Map.of("alias", "doomed", "acknowledgeNoBackup", true))); + assertFalse(Boolean.TRUE.equals(removed.isError()), textOf(removed)); + + CallToolResult afterwards = + new nostr.mcp.tool.PublishNoteTool(guard(pool, vault)) + .call(new CallToolRequest("nostr_publish_note", Map.of("content", "after removal"))); + assertTrue(Boolean.TRUE.equals(afterwards.isError()), textOf(afterwards)); + + CallToolResult stored = + new nostr.mcp.tool.QueryEventsTool(new EventQuery(pool), QueryLimits.defaults(), Clock.systemUTC()) + .call( + new CallToolRequest( + "nostr_query_events", Map.of("authors", List.of(publicKey), "kinds", List.of(1)))); + assertEquals(1, structuredOf(stored).get("count"), "only the note sent before removal should exist"); + } + } + + // Verifies the relay list reports a genuinely connected relay as connected. Against a fake + // every relay looks the same; the value of this tool is telling a live relay from a dead one, + // which is the question behind "why did my note only reach two relays". + @Test + void listRelaysReportsALiveRelayAsConnected() throws Exception { + try (RelayPool pool = pool()) { + // Publishing first forces the pool to actually open the connection it then reports on. + pool.publish(note(Identity.generateRandomIdentity(), "wake the pool " + System.nanoTime())); + + CallToolResult result = + new nostr.mcp.tool.ListRelaysTool(directoryOf(), pool) + .call(new CallToolRequest("nostr_list_relays", Map.of())); + + assertFalse(Boolean.TRUE.equals(result.isError()), textOf(result)); + assertTrue(structuredOf(result).get("relays").toString().contains(relayUri()), textOf(result)); + assertTrue(textOf(result).toLowerCase().contains("connected"), textOf(result)); + } + } + + /** A NIP-02 follow list carrying a relay hint and a petname, as a real client publishes. */ + private GenericEvent followList(Identity owner, Identity followed) { + GenericEvent event = + GenericEvent.builder() + .pubKey(owner.getPublicKey()) + .kind(3) + .content("") + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.addTag( + new nostr.event.tag.GenericTag( + "p", List.of(followed.getPublicKey().toHexString(), relayUri(), "alice"))); + event.update(); + owner.sign(event); + return event; + } + + private WriteGuard guard(RelayPool pool, IdentityVault vault) { + return new WriteGuard( + pool, vault, WritePolicy.ALLOW, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())); + } + + private RelayDirectory directoryOf() { + return new RelayDirectory(Map.of(RelayDirectory.READ, List.of(relayUri()))); + } + + private GenericEvent note(Identity author, String content) { + GenericEvent event = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.update(); + author.sign(event); + return event; + } + + private GenericEvent replyTo(Identity author, GenericEvent root, String content) { + GenericEvent reply = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + reply.addTag(new nostr.event.tag.GenericTag("e", List.of(root.getId(), "", "reply"))); + reply.update(); + author.sign(reply); + return reply; + } + + private void publish(GenericEvent event) throws Exception { + try (RelayPool pool = pool()) { + pool.publish(event); + } + } + + private IdentityVault vaultOf(String alias) { + byte[] key = + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + return new IdentityVault(sourceOf(Map.of(alias, key)), null); + } + + private IdentityVault emptyVault() { + return new IdentityVault(sourceOf(Map.of()), null); + } + + private KeySource sourceOf(Map keys) { + return new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return keys; + } + + @Override + public String type() { + return "test"; + } + }; + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), UntestedToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(RELAY_PORT); + } + + /** A store standing in for a keystore file. */ + private static final class InMemoryStore implements IdentityStore { + private final Map keys = new LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + if (keys.containsKey(alias)) { + throw new KeystoreException("already holds '" + alias + "'"); + } + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return new ArrayList<>(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/subscription/EventBufferTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/subscription/EventBufferTest.java new file mode 100644 index 00000000..55437633 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/subscription/EventBufferTest.java @@ -0,0 +1,157 @@ +package nostr.mcp.subscription; + +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies the buffer holds what arrived between polls, and admits what it lost. */ +class EventBufferTest { + + // Verifies reading empties the buffer, so polling repeatedly does not refill an agent's + // context with events it has already seen. + @Test + void drainingReturnsEachEventOnce() { + EventBuffer buffer = new EventBuffer(10); + buffer.add(note("first")); + buffer.add(note("second")); + + assertEquals(2, buffer.drain().size()); + assertEquals(List.of(), buffer.drain()); + } + + // Verifies events come back in arrival order, since a feed read out of order reads as a + // different conversation. + @Test + void eventsKeepTheirArrivalOrder() { + EventBuffer buffer = new EventBuffer(10); + buffer.add(note("first")); + buffer.add(note("second")); + buffer.add(note("third")); + + assertEquals( + List.of("first", "second", "third"), + buffer.drain().stream().map(GenericEvent::getContent).toList()); + } + + // Verifies a full buffer drops the oldest rather than refusing the newest, since a live feed + // is more useful than a stalled one. + @Test + void afullBufferDropsTheOldest() { + EventBuffer buffer = new EventBuffer(2); + buffer.add(note("oldest")); + buffer.add(note("middle")); + buffer.add(note("newest")); + + assertEquals( + List.of("middle", "newest"), + buffer.drain().stream().map(GenericEvent::getContent).toList()); + } + + // Verifies dropped events are counted, because an agent told nothing about a gap will + // summarise a partial feed as though it were the whole one. + @Test + void droppedEventsAreCounted() { + EventBuffer buffer = new EventBuffer(2); + buffer.add(note("one")); + buffer.add(note("two")); + buffer.add(note("three")); + buffer.add(note("four")); + + assertEquals(2, buffer.droppedCount()); + } + + // Verifies the drop count survives draining, so an agent polling repeatedly can still learn + // that a gap happened rather than only seeing it in the window it occurred. + @Test + void theDropCountSurvivesDraining() { + EventBuffer buffer = new EventBuffer(1); + buffer.add(note("one")); + buffer.add(note("two")); + buffer.drain(); + + assertEquals(1, buffer.droppedCount()); + } + + // Verifies a repeated event is held once, since the SDK de-duplicates over a bounded window + // and a relay replaying an old event later would otherwise look like a second post. + @Test + void aRepeatedEventIsHeldOnce() { + EventBuffer buffer = new EventBuffer(10); + GenericEvent event = note("same"); + buffer.add(event); + buffer.add(event); + + assertEquals(1, buffer.depth()); + } + + // Verifies an event repeated after a drain is accepted again, since the agent has already been + // given the first copy and dropping the second would silently lose a genuine re-delivery. + @Test + void anEventRepeatedAfterADrainIsAcceptedAgain() { + EventBuffer buffer = new EventBuffer(10); + GenericEvent event = note("same"); + buffer.add(event); + buffer.drain(); + buffer.add(event); + + assertEquals(1, buffer.depth()); + } + + // Verifies depth reflects what is waiting, which is what tells an agent whether to read. + @Test + void depthReflectsWhatIsWaiting() { + EventBuffer buffer = new EventBuffer(10); + assertEquals(0, buffer.depth()); + + buffer.add(note("one")); + assertEquals(1, buffer.depth()); + + buffer.drain(); + assertEquals(0, buffer.depth()); + } + + // Verifies concurrent arrivals are all recorded, since events reach the buffer on whichever + // thread the transport dispatches them from. + @Test + void concurrentArrivalsAreAllRecorded() throws Exception { + EventBuffer buffer = new EventBuffer(1000); + List threads = + java.util.stream.IntStream.range(0, 10) + .mapToObj( + worker -> + Thread.ofVirtual() + .unstarted( + () -> { + for (int index = 0; index < 50; index++) { + buffer.add(note("worker " + worker + " event " + index)); + } + })) + .toList(); + threads.forEach(Thread::start); + for (Thread thread : threads) { + thread.join(); + } + + assertEquals(500, buffer.depth()); + assertTrue(buffer.droppedCount() == 0, "nothing should have been dropped under the capacity"); + } + + private GenericEvent note(String content) { + Identity author = Identity.generateRandomIdentity(); + GenericEvent event = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.update(); + author.sign(event); + return event; + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/subscription/SubscriptionRegistryTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/subscription/SubscriptionRegistryTest.java new file mode 100644 index 00000000..400fd74d --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/subscription/SubscriptionRegistryTest.java @@ -0,0 +1,221 @@ +package nostr.mcp.subscription; + +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies subscriptions are handed out safely and taken back when nobody is using them. */ +class SubscriptionRegistryTest { + + private static final String RELAY = "wss://relay.one"; + + // Verifies an opened subscription is named and reachable, which is what lets an agent come + // back to it in a later turn. + @Test + void anOpenedSubscriptionCanBeFoundAgain() { + FakeRelay relay = FakeRelay.accepting(RELAY); + try (RelayPool pool = poolOf(relay); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + + LiveSubscription opened = registry.open(anyNote()); + + assertEquals(opened.id(), registry.require(opened.id()).id()); + assertEquals(List.of(opened.id()), registry.list().stream().map(LiveSubscription::id).toList()); + } + } + + // Verifies events arriving after the call returns are buffered, which is the entire reason + // subscriptions exist rather than only queries. + @Test + void eventsArrivingLaterAreBuffered() { + FakeRelay relay = FakeRelay.accepting(RELAY); + try (RelayPool pool = poolOf(relay); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + LiveSubscription opened = registry.open(anyNote()); + + relay.emitEvent(relaySubscriptionId(relay), note("arrived later")); + + await().atMost(2, TimeUnit.SECONDS).until(() -> opened.depth() == 1); + assertEquals("arrived later", opened.drain().getFirst().getContent()); + } + } + + // Verifies a subscription does not claim its backlog is drained before the relay says so, so + // an agent can tell "nothing matched yet" from "still replaying". + @Test + void theBacklogIsNotClaimedDrainedUntilTheRelaySaysSo() { + FakeRelay relay = FakeRelay.accepting(RELAY); + try (RelayPool pool = poolOf(relay); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + LiveSubscription opened = registry.open(anyNote()); + + assertFalse(opened.backlogDrained(), "the backlog was claimed drained before any signal"); + + relay.emitEndOfStoredEvents(relaySubscriptionId(relay)); + await().atMost(2, TimeUnit.SECONDS).until(opened::backlogDrained); + } + } + + // Verifies the cap turns a runaway agent into an error it can see, rather than a server that + // slowly stops responding. + @Test + void theSubscriptionCapIsEnforced() { + try (RelayPool pool = poolOf(FakeRelay.accepting(RELAY)); + SubscriptionRegistry registry = + registryOf(pool, new SubscriptionLimits(2, 10, Duration.ofHours(1)))) { + registry.open(anyNote()); + registry.open(anyNote()); + + ToolException refused = assertThrows(ToolException.class, () -> registry.open(anyNote())); + + assertEquals(ToolFailure.SUBSCRIPTION_LIMIT_REACHED, refused.getFailure()); + assertTrue(refused.getMessage().contains("nostr_unsubscribe"), refused.getMessage()); + } + } + + // Verifies closing frees a slot, so an agent that tidies up can carry on working. + @Test + void closingFreesASlot() { + try (RelayPool pool = poolOf(FakeRelay.accepting(RELAY)); + SubscriptionRegistry registry = + registryOf(pool, new SubscriptionLimits(1, 10, Duration.ofHours(1)))) { + LiveSubscription first = registry.open(anyNote()); + assertThrows(ToolException.class, () -> registry.open(anyNote()), "the cap was not enforced"); + + registry.close(first.id()); + + LiveSubscription second = registry.open(anyNote()); + assertEquals(List.of(second.id()), registry.list().stream().map(LiveSubscription::id).toList()); + } + } + + // Verifies naming a subscription that does not exist explains itself and lists what is open, + // since an agent resuming a conversation may be holding a reaped id. + @Test + void anUnknownSubscriptionIsExplained() { + try (RelayPool pool = poolOf(FakeRelay.accepting(RELAY)); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + + ToolException unknown = assertThrows(ToolException.class, () -> registry.require("sub-99")); + + assertEquals(ToolFailure.SUBSCRIPTION_UNKNOWN, unknown.getFailure()); + assertTrue(unknown.getMessage().contains("idle"), unknown.getMessage()); + } + } + + // Verifies an abandoned subscription is reaped, since an agent's session can end without this + // server ever being told and its relay traffic would otherwise run forever. + @Test + void anIdleSubscriptionIsReaped() { + try (RelayPool pool = poolOf(FakeRelay.accepting(RELAY)); + SubscriptionRegistry registry = + registryOf(pool, new SubscriptionLimits(10, 10, Duration.ofMillis(200)))) { + LiveSubscription opened = registry.open(anyNote()); + + await() + .atMost(5, TimeUnit.SECONDS) + .until(() -> registry.find(opened.id()).isEmpty()); + } + } + + // Verifies reading keeps a subscription alive, so one an agent is actually using is not reaped + // out from under it. + @Test + void readingKeepsASubscriptionAlive() throws Exception { + try (RelayPool pool = poolOf(FakeRelay.accepting(RELAY)); + SubscriptionRegistry registry = + registryOf(pool, new SubscriptionLimits(10, 10, Duration.ofMillis(400)))) { + LiveSubscription opened = registry.open(anyNote()); + + for (int poll = 0; poll < 6; poll++) { + Thread.sleep(100); + opened.drain(); + } + + assertTrue(registry.find(opened.id()).isPresent(), "an actively read subscription was reaped"); + } + } + + // Verifies a relay dropping out is recorded, so degraded coverage is visible rather than + // silently narrowing what the agent sees. + @Test + void aRelayDroppingOutIsRecorded() { + FakeRelay relay = FakeRelay.accepting(RELAY); + try (RelayPool pool = poolOf(relay); + SubscriptionRegistry registry = registryOf(pool, SubscriptionLimits.defaults())) { + LiveSubscription opened = registry.open(anyNote()); + + relay.dropConnection(); + + await().atMost(2, TimeUnit.SECONDS).until(() -> !opened.failures().isEmpty()); + assertTrue(opened.failures().containsKey(RELAY)); + } + } + + // Verifies arriving events notify the host, which is what lets a subscription push rather than + // waiting for the model to remember to poll. + @Test + void arrivingEventsNotifyTheHost() { + List notified = new CopyOnWriteArrayList<>(); + FakeRelay relay = FakeRelay.accepting(RELAY); + try (RelayPool pool = poolOf(relay); + SubscriptionRegistry registry = + new SubscriptionRegistry( + pool, SubscriptionLimits.defaults(), Clock.systemUTC(), notified::add)) { + LiveSubscription opened = registry.open(anyNote()); + + relay.emitEvent(relaySubscriptionId(relay), note("ping")); + + await().atMost(2, TimeUnit.SECONDS).until(() -> notified.contains(opened.id())); + } + } + + private String relaySubscriptionId(FakeRelay relay) { + return relay.getSentSubscriptionIds().getFirst(); + } + + private SubscriptionRegistry registryOf(RelayPool pool, SubscriptionLimits limits) { + return new SubscriptionRegistry(pool, limits, Clock.systemUTC(), subscriptionId -> {}); + } + + private RelayPool poolOf(FakeRelay relay) { + return new RelayPool(List.of(RELAY), relayUri -> relay); + } + + private EventFilter anyNote() { + return EventFilter.builder().kind(1).build(); + } + + private GenericEvent note(String content) { + Identity author = Identity.generateRandomIdentity(); + GenericEvent event = + GenericEvent.builder() + .pubKey(author.getPublicKey()) + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + event.update(); + author.sign(event); + return event; + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ListRelaysToolTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ListRelaysToolTest.java new file mode 100644 index 00000000..a56bbfce --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ListRelaysToolTest.java @@ -0,0 +1,140 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.mcp.relay.RelayDirectory; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies an agent can see which relays are configured and which are actually carrying traffic. */ +class ListRelaysToolTest { + + private static final String FIRST_RELAY = "wss://relay.one"; + private static final String SECOND_RELAY = "wss://relay.two"; + private static final String DOWN_RELAY = "wss://relay.down"; + + private final Map relays = new ConcurrentHashMap<>(); + + // Verifies every configured relay is reported with its connection state, which is what makes + // "why did my note only reach one relay" answerable. + @Test + void everyConfiguredRelayIsReportedWithItsState() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY, SECOND_RELAY)) { + CallToolResult result = toolFor(pool, FIRST_RELAY, SECOND_RELAY).call(emptyRequest()); + + List> reported = reportedRelays(result); + assertEquals(2, reported.size()); + assertEquals(FIRST_RELAY, reported.getFirst().get("uri")); + assertEquals("CONNECTED", reported.getFirst().get("state")); + } + } + + // Verifies a relay that dropped is reported as such rather than omitted, since a silently + // shorter list is how an operator fails to notice degradation. + @Test + void aRelayThatDroppedIsStillReported() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY, SECOND_RELAY)) { + relays.get(SECOND_RELAY).dropConnection(); + + List> reported = reportedRelays(toolFor(pool, FIRST_RELAY, SECOND_RELAY).call(emptyRequest())); + + assertEquals(2, reported.size(), "the dropped relay disappeared from the report"); + assertEquals("CLOSED", reported.get(1).get("state")); + } + } + + // Verifies a relay the pool never reached is reported as unreachable rather than absent, + // since its absence is exactly what an operator is asking about. + @Test + void aRelayThatWasNeverReachedIsReportedUnreachable() throws Exception { + try (RelayPool pool = + new RelayPool( + List.of(FIRST_RELAY, DOWN_RELAY), + relayUri -> { + if (DOWN_RELAY.equals(relayUri)) { + throw new IOException("connection refused"); + } + return relays.computeIfAbsent(relayUri, FakeRelay::accepting); + })) { + + List> reported = reportedRelays(toolFor(pool, FIRST_RELAY, DOWN_RELAY).call(emptyRequest())); + + assertEquals("UNREACHABLE", reported.get(1).get("state")); + } + } + + // Verifies the human-readable summary states how many relays are carrying traffic, so an + // agent can answer without parsing the structured payload. + @Test + void theSummaryStatesHowManyRelaysAreConnected() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY, SECOND_RELAY)) { + relays.get(SECOND_RELAY).dropConnection(); + + String summary = summaryOf(toolFor(pool, FIRST_RELAY, SECOND_RELAY).call(emptyRequest())); + + assertEquals("1 of 2 relays connected", summary); + } + } + + // Verifies the logical names an agent can use are reported, so it need not guess between + // "read", "write" and a raw URI. + @Test + void theLogicalRelayNamesAreReported() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY)) { + CallToolResult result = toolFor(pool, FIRST_RELAY).call(emptyRequest()); + + @SuppressWarnings("unchecked") + Iterable names = (Iterable) structured(result).get("names"); + assertTrue(names.iterator().hasNext()); + } + } + + // Verifies the tool takes no arguments, since it reports state rather than answering a query. + @Test + void theToolTakesNoArguments() throws Exception { + try (RelayPool pool = poolOf(FIRST_RELAY)) { + ListRelaysTool tool = toolFor(pool, FIRST_RELAY); + + assertEquals("nostr_list_relays", tool.name()); + assertEquals(Map.of(), tool.inputSchema().get("properties")); + } + } + + private ListRelaysTool toolFor(RelayPool pool, String... relayUris) { + return new ListRelaysTool( + new RelayDirectory(Map.of(RelayDirectory.READ, List.of(relayUris))), pool); + } + + private CallToolRequest emptyRequest() { + return new CallToolRequest("nostr_list_relays", Map.of()); + } + + @SuppressWarnings("unchecked") + private Map structured(CallToolResult result) { + return (Map) result.structuredContent(); + } + + @SuppressWarnings("unchecked") + private List> reportedRelays(CallToolResult result) { + return (List>) structured(result).get("relays"); + } + + private String summaryOf(CallToolResult result) { + return ((TextContent) result.content().getFirst()).text(); + } + + private RelayPool poolOf(String... relayUris) { + return new RelayPool( + List.of(relayUris), relayUri -> relays.computeIfAbsent(relayUri, FakeRelay::accepting)); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/NostrToolRegistryTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/NostrToolRegistryTest.java new file mode 100644 index 00000000..4ab78e65 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/NostrToolRegistryTest.java @@ -0,0 +1,77 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies the registry's own rules: ordering, and refusing a duplicate name. + * + *

Which tools a server actually exposes is pinned by {@code ToolSurfaceTest} against the real + * surface. Asserting it here too, over stubs, would only pin this test's fixture, and the golden + * file would then need updating in two places for one change. + */ +class NostrToolRegistryTest { + + // Verifies tools keep their registration order, so the golden file is stable rather than + // depending on hash iteration. + @Test + void toolsKeepTheirRegistrationOrder() { + NostrToolRegistry registry = + new NostrToolRegistry() + .register(new StubTool("nostr_second")) + .register(new StubTool("nostr_first")); + + assertEquals(List.of("nostr_second", "nostr_first"), registry.registeredNames()); + } + + // Verifies registering a duplicate name fails loudly, since which of two tools an agent got + // would otherwise depend on iteration order. + @Test + void aDuplicateToolNameIsRejected() { + NostrToolRegistry registry = new NostrToolRegistry().register(new StubTool("nostr_thing")); + + IllegalStateException thrown = + assertThrows(IllegalStateException.class, () -> registry.register(new StubTool("nostr_thing"))); + + assertEquals("A tool named nostr_thing is already registered", thrown.getMessage()); + } + + // Verifies each registered tool becomes one MCP specification carrying its own name, which is + // what lets a host discover the surface. + @Test + void eachToolBecomesOneSpecification() { + NostrToolRegistry registry = + new NostrToolRegistry() + .register(new StubTool("nostr_one")) + .register(new StubTool("nostr_two")); + + assertEquals( + List.of("nostr_one", "nostr_two"), + registry.toSpecifications().stream().map(spec -> spec.tool().name()).toList()); + } + + /** A tool that exists only to be registered. */ + private record StubTool(String name) implements NostrTool { + @Override + public String description() { + return "A tool used only in tests."; + } + + @Override + public Map inputSchema() { + return Map.of("type", "object", "properties", Map.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + return CallToolResult.builder().addTextContent("stub").build(); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/RemoveIdentityToolTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/RemoveIdentityToolTest.java new file mode 100644 index 00000000..4e4736ca --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/RemoveIdentityToolTest.java @@ -0,0 +1,190 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentityStore; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.identity.KeystoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the one irreversible tool in the module is hard to trigger by accident. + * + *

An npub with no nsec is a dead account, so these tests are about what has to be true before + * a key can be destroyed, not about the destruction itself. + */ +class RemoveIdentityToolTest { + + @TempDir Path directory; + + private IdentityVault vault; + private IdentityLifecycle lifecycle; + private RemoveIdentityTool tool; + + @BeforeEach + void setUp() { + vault = emptyVault(); + lifecycle = new IdentityLifecycle(vault, new InMemoryStore()); + lifecycle.create("personal"); + tool = new RemoveIdentityTool(lifecycle, vault, IdentityPolicy.CONFIRM); + } + + // Verifies an unbacked-up key cannot be removed at all, since an agent acting on a vague + // instruction has no basis for deciding an account is disposable. + @Test + void anUnbackedUpKeyIsRefusedOutright() { + CallToolResult refused = call(Map.of("alias", "personal")); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(textOf(refused).contains("No backup"), textOf(refused)); + assertTrue(vault.find("personal").isPresent(), "the identity was destroyed"); + } + + // Verifies the refusal names the way forward, so an agent can either back up or state that the + // key is disposable rather than simply failing. + @Test + void theRefusalNamesBothWaysForward() { + String message = textOf(call(Map.of("alias", "personal"))); + + assertTrue(message.contains("nostr_export_identity_backup"), message); + assertTrue(message.contains("acknowledgeNoBackup"), message); + } + + // Verifies a backup makes removal possible, which is the whole reason the backup tool exists. + @Test + void aBackupUnlocksRemoval() { + lifecycle.exportBackup("personal", directory.resolve("b.p12"), "pass".toCharArray()); + + CallToolResult preview = call(Map.of("alias", "personal")); + + assertFalse(Boolean.TRUE.equals(preview.isError()), textOf(preview)); + assertTrue(textOf(preview).contains("Nothing has been deleted yet"), textOf(preview)); + } + + // Verifies an explicit acknowledgement also unlocks removal, for a key the user has said is + // disposable, and that it still only previews rather than deleting at once. + @Test + void anAcknowledgementUnlocksRemovalButStillPreviews() { + CallToolResult preview = call(Map.of("alias", "personal", "acknowledgeNoBackup", true)); + + assertFalse(Boolean.TRUE.equals(preview.isError()), textOf(preview)); + assertTrue(vault.find("personal").isPresent(), "the first call deleted the key"); + } + + // Verifies the previewed key is destroyed only on the second call carrying the token. + @Test + void theSecondCallWithTheTokenDestroysTheKey() { + CallToolResult preview = call(Map.of("alias", "personal", "acknowledgeNoBackup", true)); + String token = String.valueOf(structuredOf(preview).get("confirmationToken")); + + CallToolResult removed = call(Map.of("alias", "personal", "confirmationToken", token)); + + assertFalse(Boolean.TRUE.equals(removed.isError()), textOf(removed)); + assertTrue(vault.find("personal").isEmpty(), "the key survived removal"); + } + + // Verifies an invented token destroys nothing, since a hallucinated confirmation must not be + // able to end an account. + @Test + void anInventedTokenDestroysNothing() { + CallToolResult refused = call(Map.of("alias", "personal", "confirmationToken", "invented")); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(vault.find("personal").isPresent()); + } + + // Verifies the preview says whether a backup exists, so the agent can tell the user what is + // at stake before they answer. + @Test + void thePreviewSaysWhetherABackupExists() { + CallToolResult withoutBackup = call(Map.of("alias", "personal", "acknowledgeNoBackup", true)); + assertTrue(textOf(withoutBackup).contains("No backup has been exported"), textOf(withoutBackup)); + + lifecycle.exportBackup("personal", directory.resolve("b.p12"), "pass".toCharArray()); + CallToolResult withBackup = call(Map.of("alias", "personal")); + assertTrue(textOf(withBackup).contains("A backup was exported"), textOf(withBackup)); + } + + // Verifies removing an identity that does not exist is reported rather than silently accepted. + @Test + void removingSomethingThatDoesNotExistIsReported() { + CallToolResult refused = call(Map.of("alias", "nobody", "acknowledgeNoBackup", true)); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(textOf(refused).startsWith("IDENTITY_UNKNOWN"), textOf(refused)); + } + + private CallToolResult call(Map arguments) { + return tool.call(new CallToolRequest("nostr_remove_identity", arguments)); + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } + + private IdentityVault emptyVault() { + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + /** A store standing in for a keystore file. */ + private static final class InMemoryStore implements IdentityStore { + private final Map keys = new LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + if (keys.containsKey(alias)) { + throw new KeystoreException("already holds '" + alias + "'"); + } + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return new ArrayList<>(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolCoverageTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolCoverageTest.java new file mode 100644 index 00000000..1746a424 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolCoverageTest.java @@ -0,0 +1,168 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Every registered tool must be exercised by a call somewhere in the suite. + * + *

Answers "have all the tools been tested" by measurement rather than by reading the test + * sources, where a call made through a shared helper is easy to miscount in either direction. + * A tool nobody calls is a tool whose behaviour is guaranteed only by the fact that it compiles. + * + *

Deliberately a coverage floor, not a quality claim. Passing means each tool has been + * invoked at least once; it says nothing about whether the interesting cases were covered, which + * is what the per-tool tests are for. + */ +class ToolCoverageTest { + + private static final Path TOOL_LIST = Path.of("src/test/resources/tool-list-default.txt"); + private static final Path TEST_SOURCES = Path.of("src/test/java"); + private static final Path ACCEPTANCE_HARNESS = + Path.of("../.scratch/nostr-java-mcp/accept.py"); + + private static final Path RELAY_BACKED_TESTS = Path.of("src/test/java/nostr/mcp/integration"); + private static final Path MODEL_DRIVEN_TEST = + Path.of("src/test/java/nostr/mcp/integration/OllamaAgentIT.java"); + + // Verifies every tool is exercised against a real relay, not only against fakes. Most of these + // tools exist to talk to a relay, and the failures worth catching are the ones a stand-in + // cannot produce: nostr_relay_info passed its unit tests while being unable to read any real + // relay's document. + @Test + void everyToolIsExercisedAgainstARealRelay() { + Set againstARelay = toolsCalledIn(sourcesUnder(RELAY_BACKED_TESTS)); + + List untested = + registeredTools().stream().filter(tool -> !againstARelay.contains(tool)).toList(); + + assertEquals(List.of(), untested, "these tools are never called against a real relay"); + } + + // Verifies every tool is offered to a real model and chosen for a plausible request. A tool + // can work perfectly and still be unreachable, because its description does not distinguish it + // from a neighbour, and nothing else here can detect that. + @Test + void everyToolIsReachedByAModel() { + String modelTest = read(MODEL_DRIVEN_TEST); + + List unreached = + registeredTools().stream() + .filter(tool -> !modelTest.contains('"' + tool + '"')) + .filter(tool -> !EXEMPT_FROM_MODEL_SELECTION.contains(tool)) + .toList(); + + assertEquals(List.of(), unreached, "no model-selection case reaches these tools"); + } + + /** + * Tools deliberately not offered to the model as a selection case. + * + *

Both are reached only through an explicit instruction rather than a plausible request, so + * asking a model to pick them tests the phrasing of the prompt rather than the surface. + * {@code nostr_remove_identity} is the one irreversible tool, and inviting a model to choose it + * is a bad habit to build into a test suite; {@code nostr_publish_event} is the escape hatch, + * which by design overlaps every other publishing tool. + */ + private static final Set EXEMPT_FROM_MODEL_SELECTION = + Set.of("nostr_remove_identity", "nostr_publish_event"); + + // Verifies no registered tool goes entirely uncalled by the suite. + @Test + void everyRegisteredToolIsCalledSomewhere() { + Set registered = new LinkedHashSet<>(registeredTools()); + Set called = toolsCalledAnywhere(); + + List neverCalled = registered.stream().filter(tool -> !called.contains(tool)).toList(); + + assertEquals(List.of(), neverCalled, "these tools are registered but never called by any test"); + } + + private List registeredTools() { + return read(TOOL_LIST).lines().map(String::trim).filter(line -> !line.isEmpty()).toList(); + } + + /** + * Finds every tool name that appears in a call, across the Java tests and the acceptance + * harness. + * + *

Matches the name next to a calling construct rather than anywhere in the file, so a tool + * merely named in a golden file or an assertion about the surface does not count as covered. + */ + private Set toolsCalledAnywhere() { + return toolsCalledIn(testSources()); + } + + private Set toolsCalledIn(List sources) { + Set called = new LinkedHashSet<>(); + for (Path source : sources) { + String text = read(source); + for (String tool : registeredTools()) { + if (isCalledIn(text, tool)) { + called.add(tool); + } + } + } + return called; + } + + private boolean isCalledIn(String source, String tool) { + int index = source.indexOf('"' + tool + '"'); + while (index >= 0) { + String context = source.substring(Math.max(0, index - 220), index); + if (context.contains("CallToolRequest") + || context.contains("callTool") + || context.endsWith(".tool(") + || context.endsWith("s.tool(") + || context.endsWith("probe.tool(")) { + return true; + } + index = source.indexOf('"' + tool + '"', index + 1); + } + return false; + } + + private List sourcesUnder(Path directory) { + List sources = new ArrayList<>(); + try (Stream walk = Files.walk(directory)) { + walk.filter(path -> path.toString().endsWith(".java")).forEach(sources::add); + } catch (IOException e) { + throw new UncheckedIOException("Could not walk " + directory, e); + } + return sources; + } + + private List testSources() { + List sources = new ArrayList<>(); + try (Stream walk = Files.walk(TEST_SOURCES)) { + walk.filter(path -> path.toString().endsWith(".java")).forEach(sources::add); + } catch (IOException e) { + throw new UncheckedIOException("Could not walk the test sources", e); + } + if (Files.exists(ACCEPTANCE_HARNESS)) { + sources.add(ACCEPTANCE_HARNESS); + } + return sources; + } + + private String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + file, e); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java new file mode 100644 index 00000000..57ba7545 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java @@ -0,0 +1,193 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import nostr.client.relay.FakeRelay; +import nostr.client.relay.RelayPool; +import nostr.id.Identity; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.subscription.SubscriptionLimits; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.social.McpDirectMessageService; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Walks the whole tool surface and asserts no private key can escape through it. + * + *

A key is the one thing here that cannot be un-leaked: an agent's context reaches the host's + * conversation log and usually a third-party inference API, so a key that appears in any result + * is a key that is gone. Testing each tool individually would leave the guarantee resting on + * whoever adds the next one remembering, so this walks the registry instead and fails when a + * tool is added that breaks the rule. + */ +class ToolSurfaceSecrecyTest { + + private static final String ALIAS = "personal"; + + // Verifies no tool's successful result contains the private key of any identity the server + // holds, which is the guarantee the whole vault design exists to provide. + @Test + void noToolResultContainsAPrivateKey() { + String privateKeyHex = HexFormat.of().formatHex(KEY_MATERIAL); + + try (IdentityVault vault = vaultHoldingTheKey(); + RelayPool pool = poolOf("wss://relay.one")) { + + for (NostrTool tool : surfaceOf(vault, pool)) { + CallToolResult result = tool.call(new CallToolRequest(tool.name(), Map.of())); + + assertFalse( + result.toString().toLowerCase().contains(privateKeyHex.toLowerCase()), + tool.name() + " returned the private key"); + } + } + } + + // Verifies no tool's input schema invites key material, since a tool that accepts an nsec puts + // it in the model's context before the server ever sees it. + @Test + void noToolSchemaAcceptsKeyMaterial() { + try (IdentityVault vault = vaultHoldingTheKey(); + RelayPool pool = poolOf("wss://relay.one")) { + + for (NostrTool tool : surfaceOf(vault, pool)) { + String schema = tool.inputSchema().toString().toLowerCase(); + + assertFalse(schema.contains("nsec"), tool.name() + " accepts an nsec"); + assertFalse(schema.contains("privatekey"), tool.name() + " accepts a private key"); + assertFalse(schema.contains("private_key"), tool.name() + " accepts a private key"); + assertFalse(schema.contains("secret"), tool.name() + " accepts a secret"); + } + } + } + + // Verifies the type an agent receives has no component capable of holding a key, so the + // guarantee survives someone adding a field without reading this test. + @Test + void theIdentitySummaryTypeCannotHoldAKey() { + List components = + Arrays.stream(IdentitySummary.class.getRecordComponents()) + .map(RecordComponent::getName) + .toList(); + + assertTrue(components.contains("alias")); + assertTrue(components.contains("publicKey")); + assertTrue(components.contains("npub")); + assertFalse( + components.stream().anyMatch(name -> name.toLowerCase().contains("private")), + "IdentitySummary gained a component that could hold a key: " + components); + } + + // Verifies an error naming an unknown identity lists aliases without disclosing keys, since a + // failure path is as good a leak as a success path. + @Test + void anErrorAboutAnUnknownIdentityDisclosesNoKey() { + String privateKeyHex = HexFormat.of().formatHex(KEY_MATERIAL); + + try (IdentityVault vault = vaultHoldingTheKey()) { + String message = + assertThrows( + RuntimeException.class, () -> vault.publicKeyOf("nobody")) + .getMessage(); + + assertTrue(message.contains(ALIAS), "the error should name the aliases that exist"); + assertFalse(message.toLowerCase().contains(privateKeyHex.toLowerCase())); + } + } + + /** + * The real surface, not a hand-written list. + * + *

The point of this test is to catch a tool somebody adds later, so it has to ask the same + * factory the server does. A curated list here would pass forever while the actual surface + * grew a leak, which is precisely the failure this test exists to prevent. + */ + private List surfaceOf(IdentityVault vault, RelayPool pool) { + return ToolSurface.forServer( + new RelayDirectory(Map.of(RelayDirectory.READ, List.of("wss://relay.one"))), + pool, + vault, + QueryLimits.defaults(), + Clock.systemUTC(), + new WriteGuard( + pool, + vault, + WritePolicy.ALLOW, + new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())), + WritePolicy.ALLOW, + new IdentityLifecycle(vault, new InMemoryStore()), + IdentityPolicy.ALLOW, + subscriptionRegistry(pool), + new McpDirectMessageService(vault, pool, java.util.Set.of(ALIAS))) + .tools(); + } + + private SubscriptionRegistry subscriptionRegistry(RelayPool pool) { + return new SubscriptionRegistry( + pool, SubscriptionLimits.defaults(), Clock.systemUTC(), subscriptionId -> {}); + } + + /** A store standing in for a keystore, so the surface under test is the real one. */ + private static final class InMemoryStore implements nostr.mcp.identity.IdentityStore { + private final Map keys = new java.util.LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return List.copyOf(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + } + + private IdentityVault vaultHoldingTheKey() { + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(nostr.mcp.identity.IdentityBinding binding) { + return Map.of(ALIAS, KEY_MATERIAL.clone()); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + private RelayPool poolOf(String relayUri) { + return new RelayPool(List.of(relayUri), FakeRelay::accepting); + } + + private static final byte[] KEY_MATERIAL = + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java new file mode 100644 index 00000000..a904fc4d --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java @@ -0,0 +1,313 @@ +package nostr.mcp.tool; + +import nostr.client.relay.RelayPool; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityLifecycle; +import nostr.mcp.identity.IdentityPolicy; +import nostr.mcp.identity.IdentitySummary; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.subscription.SubscriptionLimits; +import nostr.mcp.subscription.SubscriptionRegistry; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import nostr.mcp.relay.RelayDirectory; +import nostr.mcp.social.McpDirectMessageService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the real tool surface, pinned separately for bound and unbound servers. + * + *

Asserted over the tools a host actually receives rather than over stand-ins, because a + * golden file of stubs pins the test's own fixture and would stay green while the real surface + * changed underneath it. + */ +class ToolSurfaceTest { + + private static final Path UNBOUND_GOLDEN = Path.of("src/test/resources/tool-list-default.txt"); + private static final Path BOUND_GOLDEN = Path.of("src/test/resources/tool-list-bound.txt"); + private static final Path READ_ONLY_GOLDEN = + Path.of("src/test/resources/tool-list-read-only.txt"); + private static final Path NO_MUTATION_GOLDEN = + Path.of("src/test/resources/tool-list-no-identity-mutation.txt"); + + // Verifies an ordinary multi-identity server exposes exactly the golden tool list. + @Test + void theUnboundSurfaceMatchesItsGoldenFile() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + assertEquals( + readGolden(UNBOUND_GOLDEN), + String.join("\n", surfaceOf(relayPool, vault, WritePolicy.CONFIRM).registeredNames())); + } + } + + // Verifies a bound server's tool list is pinned separately, so a tool that should disappear + // under binding is asserted to be absent rather than assumed to be. The two lists are equal + // today because no keystore-mutating tool exists yet; the separate file is what will make the + // first one that does show up here as a diff instead of silently reaching a bound server. + @Test + void theBoundSurfaceMatchesItsOwnGoldenFile() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.to("personal"))) { + + assertEquals( + readGolden(BOUND_GOLDEN), + String.join("\n", surfaceOf(relayPool, vault, WritePolicy.CONFIRM).registeredNames())); + } + } + + // Verifies a bound server reports only the identity it is bound to, which is the guarantee an + // operator is buying: the other keys are not merely hidden, they were never unlocked. + @Test + void aBoundServerReportsOnlyItsBoundIdentity() { + try (IdentityVault vault = vault(IdentityBinding.to("personal"))) { + List aliases = vault.list().stream().map(IdentitySummary::alias).toList(); + + assertEquals(List.of("personal"), aliases); + } + } + + // Verifies binding leaves no identity ambiguity, since there is nothing left to choose. + @Test + void aBoundServerHasAnUnambiguousDefault() { + try (IdentityVault vault = vault(IdentityBinding.to("project-bot"))) { + assertTrue(vault.defaultAlias().isPresent()); + assertEquals("project-bot", vault.defaultAlias().orElseThrow()); + } + } + + // Verifies a read-only server registers no write tool at all, pinned by its own golden file. + // A refusal an agent can see is an invitation to rephrase; an absent tool is not. + @Test + void aReadOnlyServerRegistersNoWriteTools() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + assertEquals( + readGolden(READ_ONLY_GOLDEN), + String.join("\n", surfaceOf(relayPool, vault, WritePolicy.DENY).registeredNames())); + } + } + + // Verifies the allowing policy exposes the same tools as the confirming one, since the + // difference between them is what a call does, not which tools exist. + @Test + void allowingWritesExposesTheSameToolsAsConfirming() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + assertEquals( + surfaceOf(relayPool, vault, WritePolicy.CONFIRM).registeredNames(), + surfaceOf(relayPool, vault, WritePolicy.ALLOW).registeredNames()); + } + } + + // Verifies a server that may write but may not touch the keystore exposes no lifecycle tool, + // which is the separation identity-policy exists to provide. + @Test + void identityMutationCanBeDeniedWhileWritingIsAllowed() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + assertEquals( + readGolden(NO_MUTATION_GOLDEN), + String.join( + "\n", + surfaceOf(relayPool, vault, WritePolicy.CONFIRM, IdentityPolicy.DENY) + .registeredNames())); + } + } + + // Verifies a read-only server cannot mutate the keystore either, since identity-policy is + // capped by write-policy and a server that cannot post should not be able to destroy a key. + @Test + void aReadOnlyServerCannotMutateTheKeystoreEither() { + assertEquals( + IdentityPolicy.DENY, IdentityPolicy.fromConfiguredValue("allow", WritePolicy.DENY)); + } + + // Verifies a backend that cannot be written to offers no lifecycle tools, since a tool that + // could only ever fail is worse than one that is absent. + @Test + void aBackendThatCannotBeAdministeredOffersNoLifecycleTools() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + NostrToolRegistry registry = + ToolSurface.forServer( + directory(), + relayPool, + vault, + QueryLimits.defaults(), + Clock.systemUTC(), + writeGuard(relayPool, vault, WritePolicy.CONFIRM), + WritePolicy.CONFIRM, + null, + IdentityPolicy.ALLOW, + subscriptionRegistry(relayPool), + new McpDirectMessageService(vault, relayPool, java.util.Set.of())); + + assertEquals(readGolden(NO_MUTATION_GOLDEN), String.join("\n", registry.registeredNames())); + } + } + + /** + * Derives the identity policy from the write policy exactly as configuration does, so a test + * cannot assemble a combination a real deployment could never produce. + */ + private NostrToolRegistry surfaceOf(RelayPool relayPool, IdentityVault vault, WritePolicy policy) { + return surfaceOf( + relayPool, vault, policy, IdentityPolicy.fromConfiguredValue(null, policy)); + } + + private NostrToolRegistry surfaceOf( + RelayPool relayPool, IdentityVault vault, WritePolicy policy, IdentityPolicy identityPolicy) { + return ToolSurface.forServer( + directory(), + relayPool, + vault, + QueryLimits.defaults(), + Clock.systemUTC(), + writeGuard(relayPool, vault, policy), + policy, + new IdentityLifecycle(vault, new InMemoryStore()), + identityPolicy, + subscriptionRegistry(relayPool), + new McpDirectMessageService(vault, relayPool, java.util.Set.of())); + } + + private SubscriptionRegistry subscriptionRegistry(RelayPool relayPool) { + return new SubscriptionRegistry( + relayPool, SubscriptionLimits.defaults(), Clock.systemUTC(), subscriptionId -> {}); + } + + private WriteGuard writeGuard(RelayPool relayPool, IdentityVault vault, WritePolicy policy) { + return new WriteGuard( + relayPool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())); + } + + /** A store that accepts changes without a keystore, so the surface is what is under test. */ + private static final class InMemoryStore implements nostr.mcp.identity.IdentityStore { + private final Map keys = new LinkedHashMap<>(); + + @Override + public void store(String alias, byte[] keyMaterial) { + keys.put(alias, keyMaterial.clone()); + } + + @Override + public List aliases() { + return List.copyOf(keys.keySet()); + } + + @Override + public boolean remove(String alias) { + return keys.remove(alias) != null; + } + } + + // Verifies a bound server offers no 'identity' argument anywhere. Binding is meant to remove + // the wrong-account mistake rather than guard it, and an argument with exactly one acceptable + // value invites a model to pass a different one, turning an impossible error back into a + // possible one. Found by driving the shipped jar as a host does, not by the earlier tests. + @Test + void aBoundServerOffersNoIdentityArgumentAtAll() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.to("personal"))) { + + List offending = + surfaceOf(relayPool, vault, WritePolicy.ALLOW).tools().stream() + .filter(tool -> tool.inputSchema().toString().contains("identity")) + .map(NostrTool::name) + .toList(); + + assertEquals(List.of(), offending, "a bound server still asks which identity to use"); + } + } + + // Verifies an unbound server does still offer it, since there it is a real choice and the + // agent has to be able to express it. + @Test + void anUnboundServerStillOffersTheIdentityArgument() { + try (RelayPool relayPool = emptyPool(); + IdentityVault vault = vault(IdentityBinding.unbound())) { + + boolean anyOffersIdentity = + surfaceOf(relayPool, vault, WritePolicy.ALLOW).tools().stream() + .anyMatch(tool -> tool.inputSchema().toString().contains("identity")); + + assertTrue(anyOffersIdentity, "an unbound server must let the agent name an identity"); + } + } + + private RelayDirectory directory() { + return new RelayDirectory( + Map.of( + RelayDirectory.READ, List.of("wss://relay.example"), + RelayDirectory.WRITE, List.of("wss://relay.example"))); + } + + private RelayPool emptyPool() { + return new RelayPool(List.of(), relayUri -> { + throw new IOException("no relays in this test"); + }); + } + + private IdentityVault vault(IdentityBinding binding) { + return new IdentityVault(sourceHolding("personal", "project-bot"), null, binding); + } + + private KeySource sourceHolding(String... aliases) { + Map keys = new LinkedHashMap<>(); + for (String alias : aliases) { + keys.put( + alias, + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString())); + } + return new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + Map permitted = new LinkedHashMap<>(); + keys.forEach( + (alias, key) -> { + if (binding.permitted(keys.keySet()).contains(alias)) { + permitted.put(alias, key); + } + }); + return permitted; + } + + @Override + public String type() { + return "test"; + } + }; + } + + private String readGolden(Path goldenFile) { + try { + return Files.readString(goldenFile).strip(); + } catch (IOException e) { + throw new UncheckedIOException("Could not read " + goldenFile, e); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/transport/BindAddressTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/transport/BindAddressTest.java new file mode 100644 index 00000000..8829a7bb --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/transport/BindAddressTest.java @@ -0,0 +1,48 @@ +package nostr.mcp.transport; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the transport defaults to being unreachable from the network. + * + *

It carries no authentication, so where it listens is the only thing standing between an + * agent's keys and anyone who can route to the host. + */ +class BindAddressTest { + + // Verifies the default is loopback, so a server started with no configuration is not exposed. + @Test + void theDefaultIsLoopback() { + assertEquals("127.0.0.1", BindAddress.fromConfiguredValue(null).host()); + assertTrue(BindAddress.fromConfiguredValue(null).isLoopback()); + assertTrue(BindAddress.fromConfiguredValue(" ").isLoopback()); + } + + // Verifies the other loopback spellings are recognised, since a deployer writing 'localhost' + // has made the safe choice and should not be warned as though they had not. + @Test + void theOtherLoopbackSpellingsAreRecognised() { + assertTrue(BindAddress.fromConfiguredValue("localhost").isLoopback()); + assertTrue(BindAddress.fromConfiguredValue("127.0.0.1").isLoopback()); + assertTrue(BindAddress.fromConfiguredValue("::1").isLoopback()); + } + + // Verifies a wildcard or public bind is not treated as loopback, since that is exactly the + // case the warning exists for. + @Test + void aWildcardBindIsNotLoopback() { + assertFalse(BindAddress.fromConfiguredValue("0.0.0.0").isLoopback()); + assertFalse(BindAddress.fromConfiguredValue("192.168.1.10").isLoopback()); + } + + // Verifies an address that cannot be resolved is treated as unsafe, since assuming the + // generous interpretation of something unparseable is how a server ends up exposed. + @Test + void anUnresolvableAddressIsTreatedAsUnsafe() { + assertFalse(BindAddress.fromConfiguredValue("not a host name at all").isLoopback()); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/write/WriteGuardTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/write/WriteGuardTest.java new file mode 100644 index 00000000..a2fe0811 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/write/WriteGuardTest.java @@ -0,0 +1,260 @@ +package nostr.mcp.write; + +import nostr.client.relay.RelayPool; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies every condition a write must satisfy before it can reach a relay. */ +class WriteGuardTest { + + // Verifies a confirming server signs and holds the event rather than sending it, which is what + // makes a hallucinated post a no-op. + @Test + void confirmingHoldsTheEventInsteadOfSendingIt() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.CONFIRM); + + PendingWrite pending = guard.prepare(note("hello"), Optional.empty()); + + assertNotNull(pending.event().getSignature(), "the event was not signed"); + assertTrue(pool.published.isEmpty(), "the event was published without confirmation"); + } + } + + // Verifies the held event is what gets published, so the content cannot change between the + // preview an agent showed the user and what actually goes out. + @Test + void theConfirmedEventIsTheOneThatWasPreviewed() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.CONFIRM); + + PendingWrite pending = guard.prepare(note("exact text"), Optional.empty()); + guard.publishConfirmed(pending.token()); + + assertEquals(1, pool.published.size()); + assertEquals("exact text", pool.published.getFirst().getContent()); + assertEquals(pending.event().getId(), pool.published.getFirst().getId()); + } + } + + // Verifies a token works once, so a replayed confirmation cannot post the same note twice. + @Test + void aTokenCannotBeUsedTwice() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.CONFIRM); + PendingWrite pending = guard.prepare(note("once"), Optional.empty()); + guard.publishConfirmed(pending.token()); + + ToolException replayed = + assertThrows(ToolException.class, () -> guard.publishConfirmed(pending.token())); + + assertEquals(ToolFailure.INVALID_ARGUMENT, replayed.getFailure()); + assertEquals(1, pool.published.size()); + } + } + + // Verifies an invented token is refused, since a model that hallucinates a confirmation must + // not thereby publish anything. + @Test + void anInventedTokenIsRefused() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.CONFIRM); + + assertThrows(ToolException.class, () -> guard.publishConfirmed("made-up-token")); + assertTrue(pool.published.isEmpty()); + } + } + + // Verifies tokens are unguessable rather than sequential, so one cannot be inferred from + // another an agent has already seen. + @Test + void tokensAreUnguessable() { + try (IdentityVault vault = vaultOf("personal")) { + WriteGuard guard = guard(new RecordingPool(), vault, WritePolicy.CONFIRM); + + String first = guard.prepare(note("one"), Optional.empty()).token(); + String second = guard.prepare(note("two"), Optional.empty()).token(); + + assertNotEquals(first, second); + assertEquals(32, first.length()); + } + } + + // Verifies the allowing policy publishes without a second call, for trusted automation. + @Test + void allowingPublishesImmediately() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.ALLOW); + + guard.publishDirectly(guard.prepare(note("direct"), Optional.empty())); + + assertEquals(1, pool.published.size()); + } + } + + // Verifies a denied server refuses even if a write tool somehow reaches the guard, so the + // policy holds at the point of action and not only at registration. + @Test + void denyingRefusesAtThePointOfAction() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = guard(pool, vault, WritePolicy.DENY); + + ToolException refused = + assertThrows(ToolException.class, () -> guard.prepare(note("nope"), Optional.empty())); + + assertEquals(ToolFailure.WRITE_FORBIDDEN, refused.getFailure()); + assertTrue(pool.published.isEmpty()); + } + } + + // Verifies signing refuses to guess when several identities exist and none is the default, + // since posting as the wrong account is public and irreversible. + @Test + void severalIdentitiesWithNoDefaultRefuseToGuess() { + try (IdentityVault vault = vaultOf("personal", "project-bot")) { + WriteGuard guard = guard(new RecordingPool(), vault, WritePolicy.ALLOW); + + ToolException ambiguous = + assertThrows(ToolException.class, () -> guard.prepare(note("who?"), Optional.empty())); + + assertEquals(ToolFailure.IDENTITY_AMBIGUOUS, ambiguous.getFailure()); + assertTrue(ambiguous.getMessage().contains("project-bot"), ambiguous.getMessage()); + } + } + + // Verifies naming an unknown identity lists the real ones rather than failing opaquely. + @Test + void anUnknownIdentityListsTheRealOnes() { + try (IdentityVault vault = vaultOf("personal")) { + WriteGuard guard = guard(new RecordingPool(), vault, WritePolicy.ALLOW); + + ToolException unknown = + assertThrows( + ToolException.class, () -> guard.prepare(note("hi"), Optional.of("nobody"))); + + assertEquals(ToolFailure.IDENTITY_UNKNOWN, unknown.getFailure()); + assertTrue(unknown.getMessage().contains("personal"), unknown.getMessage()); + } + } + + // Verifies the rate limit stops a runaway agent, which is the ordinary failure rather than + // the exceptional one. + @Test + void theRateLimitStopsARunawayAgent() { + try (IdentityVault vault = vaultOf("personal")) { + RecordingPool pool = new RecordingPool(); + WriteGuard guard = + new WriteGuard( + pool, + vault, + WritePolicy.ALLOW, + new RateLimit(2, Duration.ofMinutes(1), Clock.systemUTC())); + + guard.prepare(note("one"), Optional.empty()); + guard.prepare(note("two"), Optional.empty()); + + ToolException limited = + assertThrows(ToolException.class, () -> guard.prepare(note("three"), Optional.empty())); + + assertEquals(ToolFailure.WRITE_FORBIDDEN, limited.getFailure()); + assertTrue(limited.getMessage().contains("rate limit"), limited.getMessage()); + } + } + + // Verifies the event is signed by the identity that was named, not merely by whichever key + // happened to be first. + @Test + void theEventIsSignedByTheNamedIdentity() { + try (IdentityVault vault = vaultOf("personal", "project-bot")) { + WriteGuard guard = guard(new RecordingPool(), vault, WritePolicy.ALLOW); + + PendingWrite pending = guard.prepare(note("as the bot"), Optional.of("project-bot")); + + assertEquals( + vault.publicKeyOf("project-bot").toHexString(), + pending.event().getPubKey().toHexString()); + } + } + + private WriteGuard guard(RelayPool pool, IdentityVault vault, WritePolicy policy) { + return new WriteGuard( + pool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())); + } + + private GenericEvent note(String content) { + return GenericEvent.builder() + .kind(1) + .content(content) + .createdAt(System.currentTimeMillis() / 1000) + .build(); + } + + private IdentityVault vaultOf(String... aliases) { + Map keys = new LinkedHashMap<>(); + for (String alias : aliases) { + keys.put( + alias, + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString())); + } + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return keys; + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + /** A pool that records what it was asked to publish instead of reaching a relay. */ + private static final class RecordingPool extends RelayPool { + + private final List published = new java.util.ArrayList<>(); + + private RecordingPool() { + super(List.of(), relayUri -> { + throw new IOException("no relays in this test"); + }); + } + + @Override + public nostr.client.relay.PublishResult publish(GenericEvent event) { + published.add(event); + return nostr.client.relay.PublishResult.of( + event.getId(), + List.of(nostr.client.relay.RelayPublishOutcome.accepted("wss://relay.example"))); + } + } +} diff --git a/nostr-java-mcp/src/test/resources/tool-list-bound.txt b/nostr-java-mcp/src/test/resources/tool-list-bound.txt new file mode 100644 index 00000000..a1e7931d --- /dev/null +++ b/nostr-java-mcp/src/test/resources/tool-list-bound.txt @@ -0,0 +1,16 @@ +nostr_list_relays +nostr_list_identities +nostr_query_events +nostr_get_profile +nostr_relay_info +nostr_subscribe +nostr_read_subscription +nostr_list_subscriptions +nostr_unsubscribe +nostr_fetch_thread +nostr_get_contacts +nostr_read_direct_messages +nostr_publish_note +nostr_publish_event +nostr_update_profile +nostr_send_direct_message diff --git a/nostr-java-mcp/src/test/resources/tool-list-default.txt b/nostr-java-mcp/src/test/resources/tool-list-default.txt new file mode 100644 index 00000000..3c59399c --- /dev/null +++ b/nostr-java-mcp/src/test/resources/tool-list-default.txt @@ -0,0 +1,22 @@ +nostr_list_relays +nostr_list_identities +nostr_query_events +nostr_get_profile +nostr_relay_info +nostr_subscribe +nostr_read_subscription +nostr_list_subscriptions +nostr_unsubscribe +nostr_fetch_thread +nostr_get_contacts +nostr_read_direct_messages +nostr_publish_note +nostr_publish_event +nostr_update_profile +nostr_send_direct_message +nostr_create_identity +nostr_import_identity +nostr_rename_identity +nostr_set_default_identity +nostr_export_identity_backup +nostr_remove_identity diff --git a/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt b/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt new file mode 100644 index 00000000..a1e7931d --- /dev/null +++ b/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt @@ -0,0 +1,16 @@ +nostr_list_relays +nostr_list_identities +nostr_query_events +nostr_get_profile +nostr_relay_info +nostr_subscribe +nostr_read_subscription +nostr_list_subscriptions +nostr_unsubscribe +nostr_fetch_thread +nostr_get_contacts +nostr_read_direct_messages +nostr_publish_note +nostr_publish_event +nostr_update_profile +nostr_send_direct_message diff --git a/nostr-java-mcp/src/test/resources/tool-list-read-only.txt b/nostr-java-mcp/src/test/resources/tool-list-read-only.txt new file mode 100644 index 00000000..3180b924 --- /dev/null +++ b/nostr-java-mcp/src/test/resources/tool-list-read-only.txt @@ -0,0 +1,12 @@ +nostr_list_relays +nostr_list_identities +nostr_query_events +nostr_get_profile +nostr_relay_info +nostr_subscribe +nostr_read_subscription +nostr_list_subscriptions +nostr_unsubscribe +nostr_fetch_thread +nostr_get_contacts +nostr_read_direct_messages diff --git a/pom.xml b/pom.xml index 7725cccd..8b694c15 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.0.8 + 2.3.1 pom nostr-java @@ -67,6 +67,8 @@ nostr-java-event nostr-java-identity nostr-java-client + nostr-java-api + nostr-java-mcp @@ -341,6 +343,8 @@ **/nostr/api/integration/** + + **/nostr/mcp/integration/** true @@ -356,6 +360,9 @@ **/nostr/api/integration/** + + **/nostr/mcp/integration/** + **/OllamaAgentIT.java true diff --git a/scripts/doccheck.py b/scripts/doccheck.py new file mode 100755 index 00000000..4d19ee5d --- /dev/null +++ b/scripts/doccheck.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Verify documentation against source across one or more repos. + +Usage: + doccheck [repo...] # defaults to the current directory + +Checks: + links every relative markdown link resolves to a real file + classes every backtick-quoted PascalCase name matches a real Java type + (indexed across all sibling repos, since docs cross-reference them) + config every ${ENV_VAR} in application.yml / .properties appears in + docs/configuration.md, so a real setting cannot go undocumented + +Exit code is non-zero when anything fails, so it can gate a commit. + +Written after a documentation pass in which hand-written docs referenced a +renamed class, a link at the wrong directory depth, and a LICENSE file that did +not exist. All three were invisible to review and trivial for a script. +""" +import re, sys, os +from pathlib import Path + +LINK = re.compile(r'\[([^\]]*)\]\(([^)]+)\)') +CAND = re.compile(r'`([A-Z][A-Za-z0-9]*(?:[A-Z][a-z0-9]+)+)`') +SKIP = set("""NOT NULL README CHANGELOG JSON HTTP HTTPS JVM SQL TODO PostgreSQL GitHub +JavaScript TypeScript OpenAPI PascalCase SecureRandom MessageDigest ObjectMapper JsonMapper +JavaTimeModule ParameterNamesModule SerializationFeature ResponseEntity RestController +AuthenticationPrincipal RequiredArgsConstructor GetMapping PostMapping RequestMapping +RequestBody ExceptionHandler RestControllerAdvice ConfigurationProperties ApplicationReadyEvent +UnsupportedOperationException NoSuchMethodError NoClassDefFoundError IllegalStateException +RuntimeException InterruptedException ByteArrayOutputStream BigInteger ByteBuffer +StandardCharsets SecretKeySpec GCMParameterSpec CompletableFuture ReentrantLock ThreadLocal +ConcurrentHashMap ClassNotFoundException RestTemplate TreeMap ArgumentCaptor +IOException IllegalArgumentException NullPointerException ClassCastException AutoCloseable +LinkedHashMap LinkedHashSet BigDecimal HttpClient ProcessBuilder PreparedStatement +DocumentBuilderFactory SAXParserFactory XMLInputFactory BouncyCastle SecP256K1Curve +CBORGenerator ScopedValue StructuredTaskScope VirtualThread MeterRegistry ApplicationEvent +ApplicationEventPublisherAware WebSocketHandler ScheduledExecutorService SubtleCrypto +PKPass PKStoreCard PKDateStyleMedium GenericClass GenericObject +ConnectException UnsupportedClassVersionError IllegalAccessException +InaccessibleObjectException IndexOutOfBoundsException NoSuchElement ServiceLoader KeyStore +HexFormat TypeError DOMException Uint8Array SharedArrayBuffer BroadcastChannel +SecurityContextHolder GrantedAuthority ObjectProvider RestClient FilterRegistrationBean +HealthIndicator JdkClientHttpRequestFactory StandardWebSocketClient TextWebSocketHandler +ECDomainParameters SECNamedCurves DeterministicKey RandomSource SimplePool""".split()) + +ALLOWLIST_NAME = ".doccheck-allow" + +def allowlist(root: Path): + """Names a repo declares are not Java types: alert rules, external products. + + One name per line in .doccheck-allow; blank lines and # comments ignored. + """ + f = root / ALLOWLIST_NAME + if not f.is_file(): + return set() + return {ln.split('#')[0].strip() for ln in f.read_text().splitlines() if ln.split('#')[0].strip()} + + +def allow_all_links(root: Path): + """True when .doccheck-allow declares this tree's links unmaintained. + + A superseded checkout keeps its documentation as written. Repairing its links + would imply the tree is maintained, so the marker records the decision + instead. + """ + f = root / ALLOWLIST_NAME + return f.is_file() and "doccheck: skip-links" in f.read_text() + + +# Plan and spec documents quote snippets destined for other files, including +# link lines whose relative paths are correct only from the target. Checking them +# reports the quoting, not a broken link. +QUOTED_DIRS = {"plans", "specs", "superpowers", "archive"} + + +def docs_of(root: Path): + out = [root/'README.md', root/'CLAUDE.md', root/'AGENTS.md'] + if (root/'docs').is_dir(): + out += [f for f in (root/'docs').rglob('*.md') + if not QUOTED_DIRS & set(f.relative_to(root).parts)] + return [f for f in out if f.is_file()] + +def check_config(root: Path): + """Report env vars bound in config but absent from docs/configuration.md. + + Only the reverse direction is checked. A doc may legitimately mention a var + owned by another service (a shared secret, a peer's credential), but a var + this service actually reads and nobody documented is a real gap. + """ + doc = root / "docs" / "configuration.md" + if not doc.is_file(): + return None + cfg = "" + for pat in ("**/application*.yml", "**/application*.yaml", "**/application*.properties"): + for f in root.glob(pat): + if "target" in f.parts or "/test/" in str(f): + continue + cfg += f.read_text() + if not cfg.strip(): + return None + real = set(re.findall(r"\$\{([A-Z][A-Z0-9_]*)[:}]", cfg)) + cited = set(re.findall(r"`([A-Z][A-Z0-9_]{3,})`", doc.read_text())) + return sorted(real - cited) + + +def main(argv): + repos = [Path(a).resolve() for a in argv] or [Path.cwd()] + workspace = repos[0].parent + + # index every Java type in the workspace: docs legitimately cite sibling repos + types = set() + # TypeScript declarations: several repos ship a TS client SDK whose types are + # legitimately cited in docs alongside the Java ones. + for t in list(workspace.rglob('*.ts')) + list(workspace.rglob('*.tsx')): + if {'node_modules', 'dist', 'target'} & set(t.parts): + continue + try: + text = t.read_text() + except Exception: + continue + types.update(re.findall( + r'\b(?:interface|type|class|enum)\s+([A-Z]\w*)', text)) + # React components and exported consts + types.update(re.findall(r'\b(?:const|function)\s+([A-Z]\w*)', text)) + for j in workspace.rglob('*.java'): + if 'target' in j.parts or 'node_modules' in j.parts: + continue + types.add(j.stem) + try: + text = j.read_text() + except Exception: + continue + types.update(re.findall(r'\b(?:record|class|enum|interface)\s+([A-Z]\w*)', text)) + # enum constants are legitimately cited in docs (e.g. a saga state), and + # they are not type declarations, so they need collecting separately. + for body in re.findall(r'\benum\s+\w+[^{]*\{(.*?)\}', text, re.S): + head = re.split(r';', body)[0] + types.update(re.findall(r'\b([A-Z][A-Za-z0-9_]{2,})\b', head)) + + broken, unresolved, n_links, n_types = [], [], 0, 0 + for repo in repos: + allowed = allowlist(repo) + skip_links = allow_all_links(repo) + for f in docs_of(repo): + text = f.read_text() + for _, target in LINK.findall(text): + t = target.split('#')[0].strip() + if not t or ':' in t.split('/')[0] or t.startswith('#'): + continue + if skip_links: + continue + n_links += 1 + if not (f.parent / t).resolve().exists(): + broken.append((f, t)) + for m in set(CAND.findall(text)): + if m in SKIP or m in allowed: + continue + n_types += 1 + if m not in types: + unresolved.append((f, m)) + + print(f"links: {n_links - len(broken)}/{n_links} resolve") + for f, t in broken: + print(f" BROKEN {f}\n -> {t}") + # The class check indexes sibling repos because docs legitimately cite them. + # In CI only one repo is checked out, so the index is too small to judge + # against: skip rather than emit failures that are an artefact of the + # checkout. MIN_TYPES is far below any single real repo's type count. + MIN_TYPES = 50 + if len(types) < MIN_TYPES: + print(f"classes: skipped ({len(types)} types indexed; needs sibling repos " + f"checked out, so this is a single-repo checkout)") + unresolved = [] + else: + print(f"classes: {n_types - len(unresolved)}/{n_types} resolve ({len(types)} known types)") + for f, m in unresolved: + print(f" UNKNOWN {f}: {m}") + + cfg_gaps = 0 + for repo in repos: + missing = check_config(repo) + if missing: + cfg_gaps += len(missing) + print(f"config: {repo.name} has {len(missing)} env var(s) not in docs/configuration.md") + for m in missing: + print(f" UNDOCUMENTED {m}") + if not cfg_gaps: + print("config: every bound env var is documented") + + return 1 if (broken or unresolved or cfg_gaps) else 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/release.sh b/scripts/release.sh index 7131bd40..27d6a7f9 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,7 +4,7 @@ set -euo pipefail # Automates common release tasks for nostr-java. # Subcommands: # bump --version Set root version to x.y.z and commit -# verify [--no-docker] Run mvn clean verify (optionally -DnoDocker=true) +# verify [--no-docker] Run mvn clean verify (optionally -Pno-docker) # tag --version [--push] Create annotated tag vX.Y.Z (and optionally push) # publish [--no-docker] [--repo central|398ja] # Deploy artifacts to selected repository profile @@ -23,7 +23,7 @@ Usage: $(basename "$0") [options] Commands: bump --version Set root version to x.y.z and commit verify [--no-docker] [--skip-tests] [--dry-run] - Run mvn clean verify (optionally -DnoDocker=true) + Run mvn clean verify (optionally -Pno-docker) tag --version [--push] Create annotated tag vX.Y.Z (and optionally push) publish [--no-docker] [--skip-tests] [--repo central|398ja] [--dry-run] Deploy artifacts to selected repository profile @@ -71,10 +71,44 @@ cmd_bump() { echo "Setting root version to ${version}" run_cmd mvn -q versions:set -DnewVersion="${version}" run_cmd mvn -q versions:commit - run_cmd git add pom.xml */pom.xml || true + update_documented_version "${version}" + run_cmd git add pom.xml */pom.xml docs || true run_cmd git commit -m "chore(release): bump project version to ${version}" } +# Keeps copyable install snippets in step with the version just set. +# +# DocumentationAccuracyTest fails the build when a snippet a reader would copy names a version +# other than the one being built, which is correct but means every bump would otherwise need a +# manual edit somebody eventually forgets. Only concrete versions are touched: placeholders such +# as X.Y.Z, ranges, and a migration guide's "previous version" are all deliberate. +update_documented_version() { + local version="$1" + echo "Updating documented install snippets to ${version}" + if $DRY_RUN; then + echo "+ update install snippets in docs/ to ${version}" + return + fi + python3 - "$version" <<'PYTHON' +import pathlib, re, sys + +version = sys.argv[1] +snippet = re.compile( + r"(nostr-java-[a-z]+\s*\n\s*)(\d+\.\d+\.\d+)()") + +for document in pathlib.Path("docs").rglob("*.md"): + text = document.read_text() + # A version followed by a comment is illustrative: a previous release, or a placeholder the + # reader is told to replace. Leave those alone. + updated = snippet.sub( + lambda m: m.group(0) if "