Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
22524f0
feat(event): add Rumor type and timestamp-preserving event update
Aug 30, 2026
b66f139
feat(identity): implement NIP-59 gift wrapping
Aug 30, 2026
b6c53d5
feat: add NIP-17 chat messages and direct message service
Aug 30, 2026
3568f69
feat: route direct messages to each recipient's nominated relays
Aug 30, 2026
3fbbb63
docs: document private direct messages and deprecate NIP-04
Aug 30, 2026
6940664
test: run the private direct message guide's examples
Aug 30, 2026
2ea893b
chore(release): bump project version to 2.1.0
Aug 30, 2026
6b62969
docs: record 2.1.0 in the changelog
Aug 30, 2026
f42c611
docs: record nostr-java-api module design decisions
Aug 30, 2026
4d04136
docs: add nostr-java-api spec and tracer-bullet tickets
Aug 30, 2026
29a08f5
feat(client): extract a RelayConnection seam for multi-relay work
Aug 30, 2026
9a51525
test(client): hold the relay fake to the real client's behaviour
Aug 30, 2026
a478abf
test(client): model relay recovery as a fresh connection
Aug 30, 2026
5528a9e
feat(client): publish one event to many relays with per-relay outcomes
Aug 30, 2026
33f0200
feat(client): serialise per relay and own relay reconnection
Aug 30, 2026
559feaa
feat(client): fan subscriptions in across relays as one stream
Aug 30, 2026
4f395bc
feat(api): add nostr-java-api with a NostrClient facade
Aug 30, 2026
600c785
fix(client): deliver relay frames in the order they were sent
Aug 30, 2026
2711bae
docs: document the api module in the reference and fix stale module c…
Aug 30, 2026
4e56369
test(client): mark the frame-ordering test as a weak guard
Aug 30, 2026
aaf230c
chore(release): 2.2.0
Aug 30, 2026
8f67c30
docs: rebase the MCP spec onto nostr-java-api 2.2.0
Aug 30, 2026
2895727
test(api): pin the SDK assumptions the MCP spec relies on
Aug 30, 2026
1a68422
docs: correct the thread-isolation rationale and assert the sender-co…
Aug 30, 2026
474e15a
docs: resolve the MCP spec's open questions
Aug 30, 2026
8f00963
docs: define the connection broker and state the kind-3 prerequisite …
Aug 30, 2026
60f0108
test(api): build the two designs the spec tells implementers to follow
Aug 30, 2026
7c23d82
docs: enforce the HTTP localhost constraint where it can be acted on
Aug 30, 2026
c4caac7
docs: break the MCP spec into twelve tracer-bullet tickets
Aug 30, 2026
74867ce
feat(event): model NIP-02 follow lists
Aug 30, 2026
8b1db65
feat(mcp): serve the SDK over MCP stdio
Aug 30, 2026
8da1d57
feat(mcp): hold signing identities behind a vault that never exposes …
Aug 30, 2026
3535713
feat(mcp): bind a server to one identity and administer keys from the…
Aug 30, 2026
a5e1331
feat(mcp): add the read tools, and fix the frame ordering they exposed
Aug 30, 2026
79b557a
feat(mcp): let an agent publish, but never by accident
Aug 30, 2026
777d17f
feat(mcp): manage identities, weighted by what cannot be undone
Aug 30, 2026
a579916
feat(mcp): watch for events that have not happened yet
Aug 30, 2026
cfd785d
feat(mcp): follow conversations and send private messages
Aug 30, 2026
c70ef8c
feat(mcp): serve over HTTP, bound to loopback and documented as unaut…
Aug 30, 2026
ffe96dc
feat(mcp): package the server as a jar and a container
Aug 30, 2026
21c95f7
feat(mcp): teach hosts how to use the server, and document it
Aug 30, 2026
2a43fb3
fix(mcp): omit the identity argument on a bound server
Aug 30, 2026
9afa302
test(mcp): verify every requirement against the shipped jar
Aug 30, 2026
6e0b11c
chore(release): bump versions to 2.3.0
Aug 30, 2026
440eb78
test(mcp): check that a model can actually use the tool surface
Aug 30, 2026
5ce8a76
test(mcp): call every tool, and fix the two bugs that exposed
Aug 30, 2026
770f01a
test(mcp): exercise every tool against both a relay and a model
Aug 30, 2026
30cae2b
docs: add the shared documentation checker
Aug 31, 2026
a276c7d
docs: teach doccheck about TypeScript and quoted plan snippets
Aug 31, 2026
9505634
docs: treat any URI scheme as external in doccheck
Aug 31, 2026
ddab673
docs: overhaul the documentation and test it against the source
Aug 31, 2026
f5f195b
docs: support an opt-in skip-links marker in doccheck
Aug 31, 2026
350f9b7
chore(release): remove the stale contributing guide and bump to 2.3.1
Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .doccheck-allow
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .scratch/nostr-java-api/issues/01-extract-relay-connection-seam.md
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions .scratch/nostr-java-api/issues/02-relay-pool-fan-out-publishing.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .scratch/nostr-java-api/issues/06-runtime-pool-membership.md
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions .scratch/nostr-java-api/issues/07-relay-list-lookup.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions .scratch/nostr-java-api/issues/08-nip17-direct-message-delivery.md
Original file line number Diff line number Diff line change
@@ -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<MessageDelivery>` 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
44 changes: 44 additions & 0 deletions .scratch/nostr-java-api/issues/09-nostr-client-facade.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading