innis / nostr-relay
AMPHP-based async WebSocket relay server for Nostr protocol
Requires
- php: ^8.4
- amphp/amp: ^3.0
- amphp/http-server: ^3.0
- amphp/websocket-server: ^4.0
- innis/nostr-core: ^0.10
- psr/log: ^3.0
Requires (Dev)
- ext-ffi: *
- amphp/websocket-client: ^2.0
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.2
- phpstan/phpstan: ~2.3.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.3
- v0.5.2
- v0.5.1
- v0.5.0
- v0.2.5
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.21
- v0.1.20
- v0.1.19
- v0.1.18
- v0.1.17
- v0.1.16
- v0.1.15
- v0.1.14
- v0.1.13
- v0.1.12
- v0.1.11
- v0.1.10
- v0.1.9
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-10-12 07:12:03 UTC
README
AMPHP-based async WebSocket relay server for Nostr protocol
A private, high-performance Nostr relay implementation designed to be embedded in PHP applications. Built with AMPHP for concurrent connection handling and clean architecture principles.
Features
- Interface-driven design - Storage and policies provided by host application
- AMPHP async - Non-blocking concurrent connection handling
- Private relay focus - Built for single-user/controlled access scenarios
- NIP-01 compliant - EVENT, REQ, CLOSE message handling
- NIP-09 deletion - A kind 5 deletion removes the events its signer published and, as NIP-59 asks, the gift wraps addressed to its signer; it never removes another deletion (see ADR-0038)
- NIP-11 support - Relay information document
- NIP-40 expiration - An already-expired event is refused, and an event that expires while stored is withheld from REQ results
- NIP-42 AUTH - Challenge/response authentication over nostr-core's
Nip42Validator; a challenge is issued only when a request exceeds what the connection may read (guest scope, or a gift wrap addressed to a key it has not authenticated as), or is refused asauth-requiredwhile the connection holds none (never on connect). A claim failure is answeredinvalid:unless the client answered a challenge the relay has since replaced, which is answeredauth-required:; an expiredAUTHevent is refusedinvalid:and authenticates nothing. Authenticating changes no open subscription: a client sends itsREQagain to widen one (see ADR-0033) - NIP-67 EOSE hint - A
REQnarrowed to what a guest may read ends with anAUTHchallenge and anEOSEcarrying the"auth"hint; one narrowed to nothing is refusedCLOSEDauth-required: - NIP-59 / NIP-17 gift wraps - A kind 1059 or 21059 event is served only to a connection authenticated as a key its
ptag names, whatever the policy grants. The store applies the rule on every filter before itslimit, so a recipient reads its wraps from any filter that matches them, everylimitis exact and aCOUNTis exact (see ADR-0036); a recipient may delete the wraps addressed to it - NIP-45 COUNT - COUNT message support
- NIP-50 search - A filter's
searchreaches the store with the rest of the filter; the built-inInMemoryEventStorematches it through nostr-core'sFilter, and a durable store answers it as it can - NIP-70 protected events - An event carrying a tag named
-(NIP-70's["-"], or the same name followed by any values) is admitted only from a connection authenticated as its author, whatever the policy; an unauthenticated connection is answeredauth-required, preceded by a challenge when it holds none (see ADR-0023) - Ephemeral events - Kinds 20000-29999 skip storage
- Host-owned HTTP server - The relay is an
Amp\Http\Server\RequestHandleryou mount on your ownHttpServer, so the host controls binding, middleware (CORS, forwarded headers, compression) and lifecycle, and serves its own routes on the same origin - NIP-11 metadata - Served from a single
Nip11InfoProviderInterface: the built-inStaticNip11InfoProviderfor a fixed document, or a custom implementation to compute it at runtime - Built-in RelayPolicy - Configurable tenant/guest permissions
- Real-time distribution - Events broadcast to matching subscriptions
- Metrics -
RelayInstance::getMetrics()returns aRelayMetricssnapshot (active connections, events received/sent, subscriptions, start time) via theMetricsCollectorInterfaceport - Rate limiting - DDoS protection with configurable limits; tenants (and trusted clients via
isRateLimitExempt()) bypass - Idle timeout - Connections with no inbound message for 5 minutes are closed, freeing the slot (see ADR-0005)
- PSR-3 logging - Standard logging interface
Requirements
- PHP 8.4 or higher
innis/nostr-core- Core Nostr protocol entitiesamphp/amp^3.0 - Async runtimeamphp/http-server^3.0 - HTTP serveramphp/websocket-server^4.0 - WebSocket serverpsr/log^3.0 - Logging interface
Installation
composer require innis/nostr-relay
Quick Start
1. Implement Required Interfaces
The relay requires three interfaces from your host application — the store, the policy and the relay config. Everything else has a built-in default and is wired with a named with*() call on the factory:
RelayEventStoreInterface- Event persistence and queries. Use the built-inInMemoryEventStoreto run a relay locally or to give a test a real store; it keeps everything in process memory and matches linearly, so a deployment supplies a durable implementation. It implements the replaceable and addressable rules, so storing a newer version of such an event removes the one it supersedes and storing an older one is refused asSuperseded.findByFiltersreturns aStoredEventCollection: eachStoredEventcarries the event'sEventHeader(id, author, kind, read from your indexed columns), itscreated_at, its earliest stated expiry, and the bytes you stored (new StoredEvent($header, $createdAt, $expiresAt, $encoded)). Return the union of what the filters match, each event once, newest first and, of two events created at once, the lower id first, as NIP-01 orders them, applying each filter's ownlimitto that order: the relay cannot cut your answer again, so where alimitfalls between two events created at once, your order decides which one it keeps (see ADR-0039).findByFiltersandcountByFilterstake the reader'sActingKeys, the keys the connection has authenticated as: on every filter, before itslimit, match an event of a gift-wrap kind (ActingKeys::getConfinedKinds(), 1059 and 21059) only where one of itsptags names one ofgetKeys(), and every other event as the filter alone decides; a store holdingEvents can askActingKeys::canRead(). The relay reads once perREQand counts once perCOUNT, and puts what you return in NIP-01's order again before it streams it (see ADR-0036). StoreEncodedEvent::of($event)->toJson()whenstore()is called, and hand those bytes back withEncodedEvent::fromOwnStore(); the relay streams them without parsing them, so nothing but that encoding may ever reach your store (see ADR-0039).countByFilterscounts that union, each event once, as NIP-45 aggregates several filters, and ignores each filter'slimit; it returns nostr-core'sEventCount: a store that stops counting at a ceiling reports the count as approximate, and the relay marks the NIP-45 reply accordingly (see ADR-0013).deleteByEventIdstakes the deleter'sActingKeys, holding the deletion's signer: of the ids, delete the events that key signed and the gift wraps whoseptag names it, and never a kind 5 deletion (ActingKeys::canDelete()for a store holdingEvents).deleteByCoordinatescarries the deletion'screated_at: delete a coordinate target only when the stored event is at or before it, so a version published after the retraction survives (see ADR-0038). Run your store through the library's contract cases (see Testing your store). A fault your store throws while a subscription's stored events are read ends that subscription: it is logged and the client is answeredCLOSEDerror: could not read stored events, with noEOSE. A read whose subscription the client has meanwhile replaced with a newREQon the same id, or closed, sends nothing more, and its fault is only logged, so a store that suspends while it reads never closes or answers the replacement (see ADR-0034).RelayConfigInterface- The relay's own configuration: the relay URL (for NIP-42 AUTH verification), the maximum concurrent connections, and theEventLimitsevery submitted event is validated against (the longest content, the most tags and thecreated_atwindow;new EventLimits()gives innis/nostr-core's defaults). An event outside them is refusedinvalid:, a tenant's included; publish the same numbers asmax_content_length,max_event_tags,created_at_lower_limitandcreated_at_upper_limitin your relay-information document (see ADR-0025). The listening address and trusted proxies are configured on theHttpServerthe host owns, not here. Access control can use the built-inRelayPolicyor a custom implementation ofRelayPolicyInterface. See ImplementingRelayPolicyInterfacefor how a policy signals a rejection.
Optional interfaces extend the relay's behaviour, each wired with the matching with*() call:
RateLimitPolicyInterface- Per-minute rate-limit budgets keyed byRateLimitMetric(events, subscriptions). Defaults to the built-inStaticRateLimitPolicy(60 events, 20 subscriptions per minute); implement the interface to vary limits at runtime.Nip11InfoProviderInterface- The single source of the relay's NIP-11 document. Defaults to the built-inStaticNip11InfoProviderserving an empty document for the configured relay URL; wrap a fixed document in it, or implement the interface to project metadata at runtime (e.g. reflecting live policy).AuthenticationRegistryInterface- Challenge state and authenticated sessions. Defaults to the in-memory registry; implement it to share auth state across processes.ConnectionGateInterface- Decide whether an IP may connect, before the WebSocket session is established. Defaults to allowing every IP under the configured connection cap; implement it to enforce an allow-list or deny-list.MetricsCollectorInterface- Collect relay metrics (connections, events, subscriptions). Defaults to the in-memoryInMemoryMetricsCollectorexposed viaRelayInstance::getMetrics(); implement it to export to an external monitoring system.
2. Create and Start the Relay
use Innis\Nostr\Core\Domain\ValueObject\Protocol\Nip11Info; use Innis\Nostr\Core\Infrastructure\Crypto\NativeRandomBytesGenerator; use Innis\Nostr\Relay\Application\Service\InMemoryAuthenticationRegistry; use Innis\Nostr\Relay\Application\Service\RelayPolicy; use Innis\Nostr\Relay\Domain\ValueObject\RateLimitConfig; use Innis\Nostr\Relay\Domain\ValueObject\RelayPolicyConfig; use Innis\Nostr\Relay\Infrastructure\EventStore\InMemoryEventStore; use Innis\Nostr\Relay\Infrastructure\Http\StaticNip11InfoProvider; use Innis\Nostr\Relay\Infrastructure\RateLimiting\StaticRateLimitPolicy; use Innis\Nostr\Relay\Infrastructure\Server\RelayServerFactory; use function Amp\trapSignal; $authenticationRegistry = new InMemoryAuthenticationRegistry(new NativeRandomBytesGenerator()); $logger = new \Psr\Log\NullLogger(); $policyConfig = RelayPolicyConfig::tryFromArray([ 'tenants' => ['your-hex-pubkey'], 'guest' => [ 'read' => [ ['kinds' => [0, 1, 6, 7, 30023], 'from' => 'tenants'], ], 'write' => [ ['kinds' => [7, 9735]], ], ], ]) ?? throw new RuntimeException('Invalid relay policy configuration'); $policy = new RelayPolicy($authenticationRegistry, $logger, $policyConfig); $rateLimitPolicy = new StaticRateLimitPolicy(new RateLimitConfig( eventsPerMinute: 60, subscriptionsPerMinute: 20, )); $config = new MyRelayConfig(); $nip11InfoProvider = new StaticNip11InfoProvider(Nip11Info::fromArray($config->getRelayUrl(), [ 'name' => 'My Nostr Relay', 'pubkey' => 'your-hex-pubkey', 'supported_nips' => [1, 9, 11, 17, 40, 42, 45, 50, 59, 67, 70], ])); $factory = (new RelayServerFactory( eventStore: new InMemoryEventStore(), // swap for a durable store in a deployment policy: $policy, config: $config, )) ->withRateLimitPolicy($rateLimitPolicy) ->withAuthenticationRegistry($authenticationRegistry) ->withNip11InfoProvider($nip11InfoProvider) ->withLogger($logger);
The host owns the HttpServer, so it decides the listening address, middleware and lifecycle, and mounts the relay's request handler on it. Owning the server is what lets the host serve its own routes — a landing page, a management API, static files — on the same origin as the relay:
use Amp\Http\Server\DefaultErrorHandler; use Amp\Http\Server\SocketHttpServer; use Amp\Socket\InternetAddress; use function Amp\trapSignal; $httpServer = SocketHttpServer::createForDirectAccess($logger); $httpServer->expose(new InternetAddress('127.0.0.1', 8080)); $relay = $factory->create($httpServer); $httpServer->start($relay->getRequestHandler(), new DefaultErrorHandler()); trapSignal([SIGINT, SIGTERM]); // start() is non-blocking; keep the event loop alive until interrupted $httpServer->stop();
See examples/relay.example.php for a complete runnable relay: tenant/guest policy, rate limiting, NIP-11 metadata and a stderr logger, on the built-in InMemoryEventStore.
3. Configure Nginx
The relay does not handle TLS. Use a reverse proxy for SSL. If the proxy sets X-Forwarded-For, configure the trusted proxies on the HttpServer the host owns — amphp's ForwardedMiddleware honours the header only for a matching proxy address. The relay reads the client IP from the request the server hands it, so honouring forwarded headers from an untrusted source lets any client spoof their IP; only trust a proxy you control.
upstream nostr_relay { server 127.0.0.1:8080; } server { listen 443 ssl; server_name relay.example.com; location / { proxy_pass http://nostr_relay; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } }
Policy Configuration
The built-in RelayPolicy accepts a configuration array that controls access for tenants and guests.
Tenants
tenants: array of hex pubkeys or npub strings identifying relay owners. Tenants authenticate via NIP-42 and bypass all guest restrictions, along with the rate limits and the subscription and filter caps. max_query_limit and max_filter_values are the exceptions: every filter is clamped to the first and refused above the second whoever asks, so a tenant reading more than the ceiling pages through it with until (see ADR-0027). If the array is empty or omitted, the relay operates as an open relay (all writes and reads allowed).
Limits
Optional keys with sensible defaults:
max_subscriptions- Maximum concurrent subscriptions per client. Also gatesCOUNTrequests: aCOUNTfrom a client already at the cap is rejected withblocked: too many subscriptions(see ADR-0006).max_filters- Maximum filters per subscriptionmax_query_limit- The ceiling on how many stored events one filter may return. Every filter is clamped to it, and a filter stating no limit of its own is given it, so a client asking for more receives the ceiling rather than a refusal (see ADR-0024). Any value from 1 is accepted; the relay, not the filter library, sets the ceiling. Publish it asmax_limitin your relay-information document.max_filter_values- The most values one filter may hold, counted across itsids,authors,kindsand tag conditions (default 5000). A filter above it is refused withblocked: too many values in one filter (max N), for a tenant as well as a guest, because it bounds what one store query binds; set it below your store's parameter limit (see ADR-0024). NIP-11 has no field for it.
Implementing RelayPolicyInterface
A policy answers "may this client do this?" by returning an outcome — it never throws to reject. allowEventSubmission() and allowSubscription() both return ?PolicyRejection:
use Innis\Nostr\Relay\Domain\ValueObject\PolicyRejection; public function allowEventSubmission(RelayClient $client, Event $event): ?PolicyRejection { if ($this->isSpam($event)) { return PolicyRejection::blocked('event rejected by content filter'); } if (!$this->isAuthenticated($client)) { return PolicyRejection::authRequired('authentication required to publish this event kind'); } return null; }
- Return
nullto admit. - Return
PolicyRejection::blocked($reason)for a definitive refusal. - Return
PolicyRejection::authRequired($reason)when authenticating could change the answer. When the client holds no challenge yet, the relay sends it anAUTHchallenge just before the refusal, so it can authenticate and retry. - Return
PolicyRejection::rateLimited($reason)when the client is asking too often,PolicyRejection::invalid($reason)when the event cannot be accepted on its own terms, andPolicyRejection::restricted($reason)when this relay is not open to the client at all.
The port has seven methods, and every one of them is yours to answer:
| Method | Answers |
|---|---|
allowEventSubmission() |
may this client publish this event |
allowSubscription() |
may this client open this subscription |
filterForClient() |
what this client may read, as a ScopedFilters narrowing of the filters it asked for (the store then confines gift wraps to the client's own keys, whatever this returns) |
canClientReceiveEvent() |
may this client be sent this event, checked per event on delivery against its EventHeader (id, author, kind) — see ADR-0022 |
allowsAuthentication() |
may this key authenticate here, returning ?PolicyRejection so the refusal carries your words rather than the library's |
isRateLimitExempt() |
is this client outside the rate limits and subscription caps |
offersAuthChallenge() |
should this admitted event still draw an AUTH challenge |
The relay frames the rejection as the wire reply that matches the client's message: OK for an EVENT, CLOSED for a REQ or COUNT. You do not construct wire messages yourself.
Rejections are returned rather than thrown so the analyser forces every caller to handle them — see ADR-0015. Genuine faults are still exceptions: a structurally invalid or badly-signed event raises InvalidEventException from the core validator, which the relay catches and reports as invalid:.
offersAuthChallenge() is separate from rejection: it lets a policy admit an event and still invite the client to authenticate. The challenge is sent just before the event's OK true, only once the store has accepted the event, so a duplicate is answered OK false duplicate: with no challenge (see ADR-0029). The built-in policy returns false.
Rate-Limit Exemption
RelayPolicyInterface::isRateLimitExempt() lets the policy opt specific clients out of rate limits and subscription caps. The built-in RelayPolicy exempts authenticated tenants only; rate limits apply to every untrusted client, including on an open relay (see ADR-0027). Implement RelayPolicyInterface directly to exempt other trusted clients — for example, internal services or IPs behind a trusted proxy.
Guest Rules
Unauthenticated clients are treated as guests. Guest permissions are defined under the guest key:
guest.read: array of rules controlling what events guests can query. Each rule has:
kinds(int array) - Event kinds the guest may readfrom(optional,'tenants') - Restrict results to events authored by tenants
Guests may read every kind only when guest.read is left out altogether. Once the section is present, guests read exactly the kinds its rules list and nothing else — so an empty section, or rules listing no valid kinds, means guests read nothing (see ADR-0012).
guest.write: array of rules controlling what events guests can publish. Each rule has:
kinds(int array) - Event kinds the guest may publishtagged_to_tenant(optional,true) - Require the event to tag a tenant pubkey
If no config is passed, the relay is fully open with no restrictions.
Authentication (NIP-42)
The relay does not challenge on connect. It issues an AUTH challenge only when a subscription requests something outside the guest's scope — when the requested kinds aren't guest-readable, when the requested authors aren't tenants (under from = 'tenants'), when the filter reads a tenant's mailbox (a #p tag referencing a tenant), or when a filter names a gift-wrap kind without confining it, by #p, to keys the connection has authenticated as. The challenge is an offer: a client that ignores it still receives the guest-scoped results, which end with the challenge and an EOSE carrying NIP-67's "auth" hint. A subscription narrowed until it can match nothing is refused CLOSED auth-required: instead, preceded by a challenge when the client holds none. The connection is never blocked for not authenticating. (Why a scope-exceeding request rather than every connection: see ADR-0004.)
Authenticating changes no open subscription. Each keeps the filters it was admitted under, nothing is replayed, and no second EOSE is sent; a client that wants a narrowed subscription widened sends its REQ again on the same id, which replaces it and counts once against the subscription cap (see ADR-0033).
Architecture
┌─────────────────────────────────────────────────────┐
│ Host Application │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ MyEventStore │ │ MyPolicy │ │ MyConfig │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└───────┬──────────────────┬───────────────┬──────────┘
│ │ │
┌───────▼──────────────────▼───────────────▼──────────┐
│ innis/nostr-relay │
│ │
│ WebSocket Server → Message Router → Use Cases │
│ │
│ SubscriptionRegistry → EventDistributor │
└─────────────────────────────────────────────────────┘
Relay Handles:
- WebSocket session handling (as a request handler mounted on the host's server)
- Message parsing (EVENT, REQ, CLOSE, AUTH, COUNT)
- NIP-42 authentication (challenge/response)
- NIP-09 deletion (kind 5 event processing)
- NIP-70 protected events (admitted only from their authenticated author)
- NIP-59 / NIP-17 gift wraps (served only to the key they name, and deleted at its request)
- Ephemeral event handling (kinds 20000-29999)
- Subscription management and limits
- Filter matching and event distribution
- Rate limiting
Host Application Handles:
- The HTTP server: binding, middleware (CORS, forwarded headers, compression) and start/stop lifecycle
- Event storage and queries
- Access control policies (use built-in
RelayPolicyor implementRelayPolicyInterfacedirectly) - Relay configuration (
RelayConfigInterface) - NIP-11 metadata (
Nip11InfoProviderInterface— built-inStaticNip11InfoProvider, or implement for runtime-computed metadata) - Any additional routes served on the same origin (landing page, management API, static files)
Testing
composer test
Runs the Unit, Integration and Acceptance suites, then the soak harness (tools/soak-harness.php), then PHPStan level 9 static analysis. composer test-unit runs the Unit suite alone; composer soak the harness alone.
Testing your store
The store you supply confines gift wraps and deletes them at their recipient's request, and a store that gets either wrong fails silently. The library ships the cases every store must pass, autoloaded from tests/Contract (see ADR-0037). Run them against a fresh instance of your store:
use Innis\Nostr\Relay\Tests\Contract\RelayEventStoreContract; use Innis\Nostr\Relay\Tests\Contract\RelayEventStoreContractCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class MyEventStoreContractTest extends TestCase { /** * @return array<string, array{RelayEventStoreContractCase}> */ public static function contract(): array { return RelayEventStoreContract::cases(); } #[DataProvider('contract')] public function testTheStoreKeepsTheRelayEventStoreContract(RelayEventStoreContractCase $case): void { $this->assertSame($case->getExpected(), $case->observe(new MyEventStore())); } }
Each case stores its events, then reads, counts, or deletes and reads back what remains, and names what it checks: gift wraps confined on every kind of filter, for readers with no keys, one key or several, and before the limit of a filter naming no kind, mixing kinds, looking up ids or naming authors; other events unaffected; NIP-01's order, at a tie too; filters that overlap read and counted once per event; exact counts; who may delete a gift wrap; and that a deletion removes nothing it does not name. tests/Contract ships in the dist archive and must stay there (see ADR-0037).
Manual testing with websocat:
websocat ws://localhost:8080 ["REQ","test",{"kinds":[1],"limit":10}]
Performance
The relay is designed for concurrent connection handling. Concrete throughput, latency, and memory figures depend on the host's event store and hardware and are not benchmarked here.
Design choices that bear on performance:
- AMPHP fibres for concurrent clients
- Subscriptions pre-indexed by event kind, so event distribution looks up only the subscriptions whose filters declare a matching kind (plus kind-agnostic filters) rather than scanning every subscription
- Per-event filter matching delegated to nostr-core's
Filter - Stored events streamed as the bytes the store holds, never parsed and re-encoded, and a live event encoded once however many subscribers receive it (
tools/stream-benchmark.phpmeasures both) - Non-blocking I/O throughout
License
MIT License. See LICENSE file for details.