@toon-protocol/relay 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # @toon-protocol/relay
2
2
 
3
- A Nostr relay app: free NIP-01 WebSocket reads plus an HTTP `POST /write`
4
- surface for storing events.
3
+ A Nostr relay app: free NIP-01 WebSocket reads, an HTTP `POST /write` surface
4
+ for storing events, and a free `POST /write-ephemeral` lane for presence and
5
+ typing traffic.
5
6
 
6
- The relay contains **no ILP, connector, settlement, or pricing logic**. Payment
7
- is enforced entirely upstream by an external terminator by the time a write
8
- reaches this process it is already proven paid, so the relay simply stores the
9
- event and serves reads.
7
+ The relay contains **no payment, settlement, or ILP logic**. Payment is
8
+ enforced upstream by a terminating connector; by the time a write reaches this
9
+ process it is already paid for, so the relay verifies the event signature,
10
+ stores it, and serves reads. To run one behind a payment proxy, see the
11
+ [repository README](https://github.com/toon-protocol/relay#readme).
10
12
 
11
13
  ## Install
12
14
 
@@ -14,121 +16,141 @@ event and serves reads.
14
16
  npm install @toon-protocol/relay
15
17
  ```
16
18
 
17
- ## Run (CLI)
19
+ ## Run
18
20
 
19
21
  ```bash
20
22
  NOSTR_SECRET_KEY=<64-char-hex> npx @toon-protocol/relay
21
- # reads: ws://localhost:7100
22
- # writes: http://localhost:3100/write
23
- # health: http://localhost:3100/health
23
+ # reads: ws://localhost:7100
24
+ # writes: http://localhost:3100/write
25
+ # ephemeral: http://localhost:3100/write-ephemeral
26
+ # health: http://localhost:3100/health
24
27
  ```
25
28
 
26
- | Env var | Default | Description |
27
- |---------|---------|-------------|
28
- | `TOON_SECRET_KEY` / `NOSTR_SECRET_KEY` | — | 64-char hex identity key (one of these or `TOON_MNEMONIC` is required) |
29
- | `TOON_MNEMONIC` | | BIP-39 mnemonic (NIP-06 derivation) |
30
- | `TOON_RELAY_PORT` | `7100` | WebSocket read port |
31
- | `TOON_BLS_PORT` | `3100` | HTTP write/health port |
32
- | `TOON_HOST` | `0.0.0.0` | WebSocket bind host |
33
- | `TOON_WRITE_HOST` | `0.0.0.0` | HTTP write/health bind host (see [write-port exposure](#paid-ephemeral-verify-skip-relay85)) |
34
- | `TOON_DATA_DIR` | `./data` | SQLite data directory |
35
- | `TOON_DEV_MODE` | `false` | Skip event-signature verification on `POST /write` |
36
- | `TOON_VERIFY_EPHEMERAL` | `false` | Run FULL schnorr verification on ephemeral kinds too (see below) |
37
- | `TOON_VERIFY_WORKERS` | CPUs − 1 | Worker threads for persistent-kind signature verification; `0` = inline on the event loop (automatic on 1-core boxes) |
38
- | `TOON_MAX_CONNECTIONS` | `4096` | Maximum concurrent WebSocket read connections (each costs one file descriptor — mind `ulimit -n`) |
39
-
40
- ## Paid-ephemeral verify skip (relay#85)
41
-
42
- By default the relay **skips schnorr verification for ephemeral kinds**
43
- (NIP-16, `20000 <= kind < 30000`) on `POST /write` and keeps only the SHA-256
44
- event-id check. This is a deliberate, payment-gated bypass:
45
-
46
- - **Why it is safe here:** every request reaching `POST /write` has already
47
- passed the upstream connector's payment claim gate payment is the
48
- admission/spam gate. Protocol rule: clients trust the signature chain and
49
- verify every event themselves, never the relay. Relay-side schnorr on paid
50
- ephemeral frames buys no additional trust; forging a speaker costs real
51
- money to emit frames every client discards.
52
- - **What is still checked:** the SHA-256 id check always runs, so the relay
53
- never broadcasts bytes that disagree with the event id clients verify by.
54
- - **When you MUST turn it off:** if your write port is fronted by anything
55
- other than a payment-gating connector — or you ever add a FREE ephemeral
56
- write lane — set `TOON_VERIFY_EPHEMERAL=true` (`--verify-ephemeral`,
57
- `verifyEphemeral: true`). A free lane must NOT reuse this skip.
58
- - **Exposure guard:** the write port must be reachable only via the
59
- connector. In docker, never host-publish it (`expose:`, not `ports:` —
60
- docker-published ports bypass ufw). Outside docker, bind it internally via
61
- `TOON_WRITE_HOST=127.0.0.1`. At startup the relay logs a prominent warning
62
- if the write listener binds a non-internal interface while the skip is
63
- active (warning only — container topologies legitimately bind `0.0.0.0`
64
- and stay private by not publishing the port).
65
-
66
- ## Run (programmatic)
29
+ An identity key is required: one of `--secret-key` / `TOON_SECRET_KEY` /
30
+ `NOSTR_SECRET_KEY` (64 hex characters), or `--mnemonic` / `TOON_MNEMONIC`
31
+ (BIP-39, NIP-06 derivation). Prefer the environment variables a secret
32
+ passed as a flag is visible in process listings, and the CLI warns when you do
33
+ it.
34
+
35
+ | Flag | Environment variable | Default | Description |
36
+ | ------------------------------------ | --------------------------------------- | --------- | --------------------------------------------------------------------------- |
37
+ | `--secret-key` | `TOON_SECRET_KEY`, `NOSTR_SECRET_KEY` | — | 64-hex identity key |
38
+ | `--mnemonic` | `TOON_MNEMONIC` | | BIP-39 mnemonic (NIP-06) |
39
+ | `--relay-port` | `TOON_RELAY_PORT` | `7100` | WebSocket read port |
40
+ | `--bls-port` | `TOON_BLS_PORT` | `3100` | HTTP write / health / metrics port |
41
+ | `--host` | `TOON_HOST` | `0.0.0.0` | read-port bind address |
42
+ | `--write-host` | `TOON_WRITE_HOST` | `0.0.0.0` | write-port bind address (see [exposure](#the-write-port-must-stay-private)) |
43
+ | `--data-dir` | `TOON_DATA_DIR` | `./data` | SQLite directory |
44
+ | `--dev-mode` | `TOON_DEV_MODE` | `false` | skip signature verification entirely — smoke tests only |
45
+ | `--verify-ephemeral` | `TOON_VERIFY_EPHEMERAL` | `false` | full verification on paid ephemeral kinds too |
46
+ | `--verify-workers` | `TOON_VERIFY_WORKERS` | CPUs 1 | verify-pool threads; `0` verifies on the event loop |
47
+ | `--max-connections` | `TOON_MAX_CONNECTIONS` | `4096` | concurrent WS reads (one file descriptor each) |
48
+ | `--log-writes` | `TOON_LOG_WRITES` | `false` | one log line per accepted write |
49
+ | `--no-enforce-expiration` | `TOON_ENFORCE_EXPIRATION` | enforced | serve events past their NIP-40 `expiration` again |
50
+ | `--expiration-reap-grace-seconds` | `TOON_EXPIRATION_REAP_GRACE_SECONDS` | `86400` | how long an expired event stays on disk |
51
+ | `--expiration-reap-interval-seconds` | `TOON_EXPIRATION_REAP_INTERVAL_SECONDS` | `3600` | how often the reaper sweeps; `0` never |
52
+ | `--blocked-event-ids` | `TOON_BLOCKED_EVENT_IDS` | — | comma-separated 64-hex event ids to refuse |
53
+ | `--ephemeral-rate-limit` | `TOON_EPHEMERAL_RATE_LIMIT` | `200` | free-lane requests per key per window |
54
+ | `--ephemeral-rate-window-ms` | `TOON_EPHEMERAL_RATE_WINDOW_MS` | `10000` | free-lane rate-limit window |
55
+ | `--ephemeral-max-body-bytes` | `TOON_EPHEMERAL_MAX_BODY_BYTES` | `8192` | free-lane request body cap |
56
+
57
+ Retention behaviour NIP-40 expiry, NIP-09 deletion, the blocklist is
58
+ documented in
59
+ [`docs/retention.md`](https://github.com/toon-protocol/relay/blob/main/docs/retention.md).
67
60
 
68
- ```ts
69
- import { startRelay } from '@toon-protocol/relay';
61
+ ## HTTP surface
70
62
 
71
- const relay = await startRelay({ secretKey });
72
- // ... POST /write on 3100, read NIP-01 on 7100 ...
73
- await relay.stop();
74
- ```
63
+ | Method | Path | Description |
64
+ | ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Verifies the signature (ephemeral kinds: id check only, by default). Echoes the connector's payment statement when there is one — see below. |
66
+ | `POST` | `/write-ephemeral` | The free ephemeral lane. Ephemeral kinds only (`400` otherwise), always fully verified, never stored, rate-limited and size-capped (`429` / `413`). |
67
+ | `GET` | `/health` | Liveness, identity (`pubkey`), `capabilities`, `version`. |
68
+ | `GET` | `/metrics` | JSON telemetry: event-loop delay, per-event verify timings and pool state, and the ephemeral lane's bounds. |
75
69
 
76
- ## HTTP surface
70
+ ### The payment statement
77
71
 
78
- | Method | Path | Description |
79
- |--------|------|-------------|
80
- | `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Trusts injected `X-TOON-Payer`/`-Amount`/`-Chain` headers (echoed, not validated); verifies only the event signature (ephemeral kinds: id check only by default, see above). |
81
- | `GET` | `/health` | Liveness, identity (`pubkey`), `capabilities`, and `version`. |
82
- | `GET` | `/metrics` | JSON telemetry: `eventLoopDelayMs` (mean/p50/p99/max — loop lag is ephemeral-frame tail latency) and `verify` (per-event verify wall time incl. pool queueing, active implementation, worker count). The trigger metrics for scaling decisions (relay#85). |
72
+ A terminating connector states three headers on a delivery whose payment it
73
+ verified at its own client edge:
83
74
 
84
- ## WebSocket Relay Server
75
+ | Header | Value |
76
+ | --------------- | -------------------------------------------------------------- |
77
+ | `X-TOON-Payer` | the client channel key — `evm:0x<64 hex>` or `solana:<base58>` |
78
+ | `X-TOON-Amount` | the route's price, decimal, in base units |
79
+ | `X-TOON-Chain` | `evm` or `solana` |
85
80
 
86
- NIP-01 compliant WebSocket server that stores and serves Nostr events in TOON format.
81
+ When all three are present and well-formed, `POST /write` returns them as a
82
+ `payment` object and includes them on its log line. The relay re-validates
83
+ none of it — it holds no chain state — but a malformed or partial statement is
84
+ discarded whole rather than half-recorded.
87
85
 
88
- ```ts
89
- import { NostrRelayServer, SqliteEventStore } from '@toon-protocol/relay';
86
+ **Absence is not "unpaid".** The headers are stated only by the hop that took
87
+ the payment, so they are absent on a forwarded packet and on every free route.
88
+ Never treat their absence as a reason to refuse a write.
90
89
 
91
- const eventStore = new SqliteEventStore('./events.db');
92
- const relay = new NostrRelayServer({ port: 7100 }, eventStore);
90
+ ## The write port must stay private
93
91
 
94
- await relay.start();
95
- relay.broadcastEvent(event); // push to matching subscriptions
96
- await relay.stop();
97
- ```
92
+ By default the relay **skips signature verification for ephemeral kinds**
93
+ (NIP-16, `20000 <= kind < 30000`) on `POST /write`, keeping only the SHA-256
94
+ id check. That is a deliberate, payment-gated bypass:
98
95
 
99
- ## Event Storage
96
+ - **Why it is safe:** every request reaching `POST /write` has already passed
97
+ the connector's payment gate — payment is the admission control. Clients
98
+ verify every signature themselves and never trust the relay's verdict, so
99
+ relay-side verification of paid ephemeral frames buys no trust that payment
100
+ has not already bought.
101
+ - **What is still checked:** the id check always runs, so the relay never
102
+ broadcasts bytes that disagree with the id clients index by.
103
+ - **When to turn it off:** if the write port is fronted by anything other than
104
+ a payment-gating connector, set `TOON_VERIFY_EPHEMERAL=true`.
100
105
 
101
- ```ts
102
- import { InMemoryEventStore, SqliteEventStore } from '@toon-protocol/relay';
106
+ In Docker, never host-publish the write port (`expose:`, not `ports:` — a
107
+ docker publish bypasses ufw). Outside Docker, bind it with
108
+ `TOON_WRITE_HOST=127.0.0.1`. The relay logs a warning at startup if the write
109
+ listener binds a non-internal interface while the skip is active.
103
110
 
104
- const memStore = new InMemoryEventStore(); // ephemeral
105
- const sqlStore = new SqliteEventStore('./events.db'); // persistent
111
+ ## The free ephemeral lane
106
112
 
107
- memStore.store(event);
108
- const found = memStore.get(event.id);
109
- const results = memStore.query([{ kinds: [1], limit: 10 }]);
110
- ```
113
+ `POST /write-ephemeral` is a second write surface, distinct from the paid one,
114
+ because a connector cannot carry two prices on a single handler URL. It is
115
+ terminated by its own zero-priced route.
116
+
117
+ It accepts **only** ephemeral kinds, **never** stores, and **always** verifies
118
+ signatures in full — it has no payment gate to lean on, so verification is its
119
+ only defence against forged-signature spam. It is bounded by a per-key sliding
120
+ window and a body-size cap, both surfaced on `GET /metrics`.
111
121
 
112
- ## TOON Codec
122
+ ## Programmatic use
123
+
124
+ ```ts
125
+ import { startRelay } from '@toon-protocol/relay';
113
126
 
114
- Vendored in-repo (`src/toon/codec.ts`) so the relay depends only on the lightweight `@toon-format/toon` encoder rather than `@toon-protocol/core`'s full transitive tree. The relay has no runtime dependency on `@toon-protocol/core`.
127
+ const relay = await startRelay({ secretKey });
128
+ // POST /write on 3100, NIP-01 reads on 7100
129
+ await relay.stop();
130
+ ```
115
131
 
116
132
  ```ts
117
- import { encodeEventToToon, decodeEventFromToon } from '@toon-protocol/relay';
133
+ import { NostrRelayServer, SqliteEventStore } from '@toon-protocol/relay';
134
+
135
+ const eventStore = new SqliteEventStore('./events.db');
136
+ const relay = new NostrRelayServer({ port: 7100 }, eventStore);
137
+
138
+ await relay.start();
139
+ relay.broadcastEvent(event); // push to matching subscriptions
140
+ await relay.stop();
118
141
  ```
119
142
 
120
- ## Full API
121
-
122
- | Category | Exports |
123
- |----------|---------|
124
- | **Launcher** | `startRelay`, `RelayConfig`, `RelayInstance`, `RelaySubscription`, `ResolvedRelayConfig` |
125
- | **Relay** | `NostrRelayServer`, `ConnectionHandler`, `RelayServerConfig`, `DEFAULT_RELAY_CONFIG` |
126
- | **Storage** | `EventStore`, `InMemoryEventStore`, `SqliteEventStore`, `RelayError` |
127
- | **Write/Health** | `createWriteHandler`, `createHealthResponse` |
128
- | **Codec** | `encodeEventToToon`, `decodeEventFromToon`, `ToonEncodeError`, `ToonDecodeError` |
129
- | **Subscriber** | `RelaySubscriber`, `RelaySubscriberConfig` |
130
- | **Filter** | `matchFilter` |
131
- | **Constants** | `VERSION` |
143
+ | Category | Exports |
144
+ | --------------------- | ---------------------------------------------------------------------------------------- |
145
+ | **Launcher** | `startRelay`, `RelayConfig`, `RelayInstance`, `RelaySubscription`, `ResolvedRelayConfig` |
146
+ | **Relay** | `NostrRelayServer`, `ConnectionHandler`, `RelayServerConfig`, `DEFAULT_RELAY_CONFIG` |
147
+ | **Storage** | `EventStore`, `InMemoryEventStore`, `SqliteEventStore`, `RelayError` |
148
+ | **Write / health** | `createWriteHandler`, `createEphemeralWriteHandler`, `createHealthResponse` |
149
+ | **Payment statement** | `readPaymentAttribution`, `PaymentAttribution` |
150
+ | **Rate limiting** | `createRateLimiter` |
151
+ | **Subscriber** | `RelaySubscriber`, `RelaySubscriberConfig` |
152
+ | **Filter** | `matchFilter` |
153
+ | **Constants** | `VERSION` |
132
154
 
133
155
  ## License
134
156