PHPackages                             innis/nostr-relay - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. innis/nostr-relay

ActiveLibrary[HTTP &amp; Networking](/categories/http)

innis/nostr-relay
=================

AMPHP-based async WebSocket relay server for Nostr protocol

v0.6.3(6d ago)084↓70%1MITPHPPHP ^8.4CI passing

Since Mar 25Pushed 2w agoCompare

[ Source](https://github.com/johninnis/nostr-relay)[ Packagist](https://packagist.org/packages/innis/nostr-relay)[ RSS](/packages/innis-nostr-relay/feed)WikiDiscussions master Synced yesterday

READMEChangelogDependencies (66)Versions (37)Used By (1)

innis/nostr-relay
=================

[](#innisnostr-relay)

[![CI](https://github.com/johninnis/nostr-relay/actions/workflows/ci.yml/badge.svg)](https://github.com/johninnis/nostr-relay/actions/workflows/ci.yml)

**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
--------

[](#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** - Kind 5 event processing
- **NIP-11 support** - Relay information document
- **NIP-42 AUTH** - Challenge/response authentication; a challenge is issued only when a subscription exceeds guest scope (never on connect), and the client's live subscriptions are re-evaluated once it authenticates
- **NIP-45 COUNT** - COUNT message support
- **Ephemeral events** - Kinds 20000-29999 skip storage
- **Host-owned HTTP server** - The relay is an `Amp\Http\Server\RequestHandler` you mount on your own `HttpServer`, 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-in `StaticNip11InfoProvider` for 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 a `RelayMetrics` snapshot (active connections, events received/sent, subscriptions, start time) via the `MetricsCollectorInterface` port
- **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](docs/adr/0005-idle-connections-closed-after-a-fixed-timeout.md))
- **PSR-3 logging** - Standard logging interface

---

Requirements
------------

[](#requirements)

- PHP 8.4 or higher
- `innis/nostr-core` - Core Nostr protocol entities
- `amphp/amp` ^3.0 - Async runtime
- `amphp/http-server` ^3.0 - HTTP server
- `amphp/websocket-server` ^4.0 - WebSocket server
- `psr/log` ^3.0 - Logging interface

---

Installation
------------

[](#installation)

```
composer require innis/nostr-relay
```

---

Quick Start
-----------

[](#quick-start)

### 1. Implement Required Interfaces

[](#1-implement-required-interfaces)

The relay requires these interfaces from your host application:

- **`RelayEventStoreInterface`** - Event persistence and queries. Use the built-in `InMemoryEventStore` to 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.
- **`RelayConfigInterface`** - The relay's own configuration: the relay URL (for NIP-42 AUTH verification) and the maximum concurrent connections. The listening address and trusted proxies are configured on the `HttpServer` the host owns, not here.
- **`RateLimitPolicyInterface`** - Per-minute rate-limit budgets keyed by `RateLimitMetric` (events, subscriptions). Use the built-in `StaticRateLimitPolicy` for fixed limits, or implement the interface to vary limits at runtime.
- **`Nip11InfoProviderInterface`** - The single source of the relay's NIP-11 document. Wrap a fixed document in the built-in `StaticNip11InfoProvider`, or implement the interface to project metadata at runtime (e.g. reflecting live policy).

Access control can use the built-in `RelayPolicy` or a custom implementation of `RelayPolicyInterface`. See [Implementing `RelayPolicyInterface`](#implementing-relaypolicyinterface) for how a policy signals a rejection.

Optional interfaces extend the relay's behaviour:

- **`ConnectionGateInterface`** - Decide whether an IP may connect, before the WebSocket session is established. Defaults to allowing every IP; implement it to enforce an allow-list or deny-list.
- **`MetricsCollectorInterface`** - Collect relay metrics (connections, events, subscriptions). Defaults to the in-memory `InMemoryMetricsCollector` exposed via `RelayInstance::getMetrics()`; implement it to export to an external monitoring system.

### 2. Create and Start the Relay

[](#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, 42, 45],
]));

$factory = new RelayServerFactory(
    eventStore: new InMemoryEventStore(), // swap for a durable store in a deployment
    policy: $policy,
    config: $config,
    rateLimitPolicy: $rateLimitPolicy,
    authenticationRegistry: $authenticationRegistry,
    logger: $logger,
    nip11InfoProvider: $nip11InfoProvider,
);
```

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`](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

[](#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
--------------------

[](#policy-configuration)

The built-in `RelayPolicy` accepts a configuration array that controls access for tenants and guests.

### Tenants

[](#tenants)

`tenants`: array of hex pubkeys or npub strings identifying relay owners. Tenants authenticate via NIP-42 and bypass all guest restrictions. If the array is empty or omitted, the relay operates as an open relay (all writes and reads allowed).

### Limits

[](#limits)

Optional keys with sensible defaults:

- `max_subscriptions` - Maximum concurrent subscriptions per client. Also gates `COUNT` requests: a `COUNT` from a client already at the cap is rejected with `blocked: too many subscriptions` (see [ADR-0006](docs/adr/0006-count-and-req-share-one-subscription-cap.md)).
- `max_filters` - Maximum filters per subscription
- `max_event_size` - Maximum event payload size in bytes
- `max_query_limit` - Maximum limit value in REQ filters

### Implementing `RelayPolicyInterface`

[](#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 `null` to admit.
- Return `PolicyRejection::blocked($reason)` for a definitive refusal.
- Return `PolicyRejection::authRequired($reason)` when authenticating could change the answer. The relay additionally sends the client an `AUTH` challenge alongside the refusal, so it can authenticate and retry.

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-0003](docs/adr/0003-anticipated-outcomes-returned-faults-thrown.md). 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 (see [ADR-0011](docs/adr/0011-an-accepted-write-may-draw-a-lazy-auth-challenge.md)). The built-in policy returns `false`.

### Rate-Limit Exemption

[](#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 (and everyone on an open relay). Implement `RelayPolicyInterface` directly to exempt other trusted clients — for example, internal services or IPs behind a trusted proxy.

### Guest Rules

[](#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 read
- `from` (optional, `'tenants'`) - Restrict results to events authored by tenants

**`guest.write`**: array of rules controlling what events guests can publish. Each rule has:

- `kinds` (int array) - Event kinds the guest may publish
- `tagged_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)

[](#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'`), or when the filter reads a **tenant's mailbox** (a `#p` tag referencing a tenant). The challenge is an offer: a client that authenticates gains full scope, while a client that ignores it still receives the guest-scoped results. The connection is never blocked for not authenticating. (Why a scope-exceeding request rather than every connection: see [ADR-0004](docs/adr/0004-auth-challenge-only-on-scope-exceeding-request.md).)

When a client authenticates, its already-open subscriptions are re-evaluated against its new scope: each is re-admitted with its original filters and the now-visible stored events are streamed, so a subscription opened as a guest widens automatically without the client having to re-subscribe (see [ADR-0007](docs/adr/0007-authentication-restreams-already-open-subscriptions.md)).

---

Architecture
------------

[](#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)
- 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 `RelayPolicy` or implement `RelayPolicyInterface` directly)
- Relay configuration (`RelayConfigInterface`)
- NIP-11 metadata (`Nip11InfoProviderInterface` — built-in `StaticNip11InfoProvider`, or implement for runtime-computed metadata)
- Any additional routes served on the same origin (landing page, management API, static files)

---

Testing
-------

[](#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.

Manual testing with [websocat](https://github.com/vi/websocat):

```
websocat ws://localhost:8080

["REQ","test",{"kinds":[1],"limit":10}]
```

---

Performance
-----------

[](#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`
- Non-blocking I/O throughout

---

License
-------

[](#license)

MIT License. See LICENSE file for details.

###  Health Score

46

—

FairBetter than 92% of packages

Maintenance98

Actively maintained with recent releases

Popularity12

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity54

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

36

Last Release

6d ago

PHP version history (2 changes)v0.1.0PHP ^8.3

v0.5.0PHP ^8.4

### Community

Maintainers

![](https://www.gravatar.com/avatar/d2ca81e761ec3b2d4ce98ac59cd4394bb4d70ebf3d1e620ff154ffcfc7a34bfc?d=identicon)[innis](/maintainers/innis)

---

Top Contributors

[![johninnis](https://avatars.githubusercontent.com/u/242370111?v=4)](https://github.com/johninnis "johninnis (111 commits)")

---

Tags

asyncwebsocketprotocolamphpRelaynostr

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/innis-nostr-relay/health.svg)

```
[![Health](https://phpackages.com/badges/innis-nostr-relay/health.svg)](https://phpackages.com/packages/innis-nostr-relay)
```

###  Alternatives

[symfony/http-kernel

Provides a structured process for converting a Request into a Response

8.1k886.6M9.6k](/packages/symfony-http-kernel)[amphp/http-server

A non-blocking HTTP application server for PHP based on Amp.

1.3k7.8M124](/packages/amphp-http-server)[danog/madelineproto

Async PHP client API for the telegram MTProto protocol.

3.5k920.5k24](/packages/danog-madelineproto)[amphp/websocket-client

Async WebSocket client for PHP based on Amp.

1646.1M71](/packages/amphp-websocket-client)[api-platform/metadata

API Resource-oriented metadata attributes and factories

275.5M254](/packages/api-platform-metadata)[amphp/websocket

Shared code for websocket servers and clients.

466.2M11](/packages/amphp-websocket)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
