Files
udp_nostr/docs-ideas/udp_nostr_relay_plan.md
2026-08-12 16:34:22 -04:00

206 lines
8.3 KiB
Markdown

# UDP Nostr Relay — Architecture Plan
## Overview
A web page where a user signs in with their Nostr key, creates a message, and sends it as a single UDP datagram to a relay. The relay validates the signature, stores the event, and serves it to subscribers via the standard Nostr WebSocket protocol.
---
## Key Constraints Discovered
### 1. Browsers Cannot Send UDP
This is the critical constraint. Browsers have **no JavaScript API** for sending raw UDP datagrams to arbitrary hosts. The only UDP-like capability is WebRTC, which is designed for peer-to-peer media streams via ICE/STUN/TURN — not for firing a single datagram at a server.
**Implication:** The web page cannot send UDP directly. It must send the signed event to a **bridge** via HTTP or WebSocket, and the bridge fires the UDP datagram to the relay.
### 2. TCP and UDP Ports Are Separate Namespaces
A server can have **both** a TCP listener and a UDP listener on the **same port number** without conflict. This is how QUIC/HTTP3 works — it's UDP on port 443 alongside the HTTPS server on TCP port 443.
**Implication:** You can run a UDP Nostr receiver on port 443 even if your web server (nginx, caddy) is already using TCP port 443. They do not interfere.
### 3. The Relay Must Serve Subscribers via WebSocket
Nostr relays communicate with subscribers via WebSocket (NIP-01). The UDP receiver is just the **ingress** path. The relay still needs a WebSocket server for subscribers to connect and receive events.
---
## Architecture
```mermaid
flowchart LR
subgraph "User's Browser"
WP[Web Page<br/>Nostr sign-in + compose]
EXT[Nostr Extension<br/>nos2x / Alby / nsec]
end
subgraph "Relay Server"
BR[Bridge<br/>HTTP endpoint<br/>receives signed event]
UDPR[UDP Receiver<br/>port 443 or 8888<br/>listens for datagrams]
VAL[Signature Validator<br/>verifies event sig]
STORE[Event Store<br/>in-memory array]
WS[WebSocket Server<br/>port 8008<br/>Nostr relay protocol]
end
subgraph "Subscriber"
SUB[Nostr client<br/>connects via WebSocket]
end
WP -- "HTTP POST /publish" --> BR
WP -- "sign with nsec" --> EXT
BR -- "fire UDP datagram" --> UDPR
UDPR -- "raw event bytes" --> VAL
VAL -- "valid event" --> STORE
STORE -- "new event" --> WS
WS -- "NIP-01 EVENT message" --> SUB
style BR fill:#48a,color:#fff
style UDPR fill:#4a4,color:#fff
style WS fill:#a84,color:#fff
```
### Data Flow
1. **User composes message** in the web page, signs it with their Nostr key (via browser extension or in-page nsec)
2. **Web page POSTs** the signed JSON event to the bridge HTTP endpoint
3. **Bridge** fires the event as a single UDP datagram to the relay's UDP receiver (could be localhost or a remote address)
4. **UDP Receiver** receives the datagram, checks it fits in one packet (under 1472 bytes)
5. **Signature Validator** verifies the Schnorr signature against the pubkey in the event
6. **Event Store** appends the valid event to an in-memory array
7. **WebSocket Server** broadcasts the new event to all connected subscribers per NIP-01
---
## Component Specifications
### 1. Web Page (`udp_nostr_web/`)
**Files:**
- `index.html` — The page layout
- `app.js` — Nostr key management, event creation, signing, HTTP POST
- `style.css` — Minimal styling
**Capabilities:**
- Sign in with nsec (text input) or browser extension (nos2x/Alby via `window.nostr`)
- Compose a text message
- Create a Nostr event (kind 1) with the message as content
- Sign the event
- POST the signed JSON to the bridge at `/publish`
- Display the response (success/failure, event ID, size check)
**Constraints enforced client-side:**
- Warn if content exceeds ~1135 characters (would exceed 1472 byte UDP limit)
- Show the total JSON size before sending
### 2. Bridge + UDP Receiver + Relay (Single Node.js Process)
**File:** `udp_nostr_relay.js`
This is a single process that combines three roles:
#### A. HTTP Bridge (port 3000)
| Endpoint | Method | Body | Response |
|---|---|---|---|
| `/publish` | POST | Signed Nostr event JSON | `{ ok: true, id, size }` or `{ ok: false, error }` |
On receiving a valid event:
1. Validates the JSON structure (required fields present)
2. Fires the event as a UDP datagram to the UDP receiver (localhost:UDPPORT)
3. Sets the DF flag (Don't Fragment) to ensure single-packet delivery
4. Returns success/failure to the browser
#### B. UDP Receiver (port 443 or 8888)
- Listens for UDP datagrams
- Checks size ≤ 1472 bytes (single-packet guarantee)
- Parses JSON
- Validates required Nostr fields
- Verifies the Schnorr signature using the pubkey in the event
- If valid: stores the event and broadcasts to WebSocket subscribers
- If invalid: silently drops (no response — no handshake to probe)
#### C. WebSocket Server (port 8008)
- Implements the Nostr relay protocol (NIP-01 subset):
- `REQ` message: subscriber requests events (returns stored events + streams new ones)
- `EVENT` message: subscriber publishes an event (alternative to UDP path)
- `CLOSE` message: subscriber unsubscribes
- On new valid event (from UDP or WebSocket), broadcasts `EVENT` to all matching subscribers
### 3. Signature Verification
Uses the `@noble/secp256k1` library (pure JS, no native deps) to verify Schnorr signatures:
```
event_id = sha256(serialized_event_without_id_and_sig)
valid = schnorr_verify(event_id, sig, pubkey)
```
The event ID is computed from the serialized event fields `[0, pubkey, created_at, kind, tags, content]` per NIP-01.
---
## Port Strategy
| Port | Protocol | Service | Notes |
|---|---|---|---|
| 443 | **UDP** | Nostr UDP Receiver | Blends with QUIC/HTTP3 traffic. Does NOT conflict with TCP 443 (HTTPS). |
| 3000 | TCP | HTTP Bridge | Receives events from the web page. Could also be 443 if using a reverse proxy. |
| 8008 | TCP | WebSocket Relay | Standard Nostr relay port. Subscribers connect here. |
### Why Port 443 for UDP?
- **Censorship resistance:** UDP on port 443 is indistinguishable from QUIC/HTTP3 traffic to a passive observer. Blocking it would break a significant portion of the web.
- **No conflict:** TCP and UDP have separate port namespaces. Your HTTPS server (nginx on TCP 443) and the UDP Nostr receiver (UDP 443) coexist peacefully.
- **Practical consideration:** Some cloud providers and firewalls may block UDP on non-standard ports. Port 443 is universally allowed.
### Tradeoff
If you already have a service using **UDP on port 443** (e.g., a QUIC-enabled web server), you cannot have two UDP listeners on the same port. In that case, use a nearby port like 4443 or 8888.
---
## Implementation Plan
### Step 1: Create the Relay (`udp_nostr_relay.js`)
A single Node.js process with three components:
```
udp_nostr_relay/
├── relay.js # Main entry point — starts all three servers
├── package.json # Dependencies: @noble/secp256k1, ws
├── udp_receiver.js # UDP listener, signature validation, event storage
├── http_bridge.js # HTTP endpoint for browser POST
├── ws_relay.js # WebSocket Nostr relay for subscribers
└── event_store.js # In-memory event storage with subscription matching
```
### Step 2: Create the Web Page (`udp_nostr_web/`)
```
udp_nostr_web/
├── index.html # The page
├── app.js # Nostr key mgmt, signing, HTTP POST
└── style.css # Minimal styling
```
### Step 3: Test the Full Flow
1. Start the relay: `node relay.js`
2. Open the web page in a browser
3. Enter nsec or connect extension
4. Compose a message and send
5. Verify the event appears in the relay
6. Connect a Nostr client (e.g., `noscl` or another browser tab) via WebSocket to port 8008
7. Verify the subscriber receives the event
---
## Open Questions for You
1. **Port preference:** Do you want the UDP receiver on port 443 (blends with QUIC) or a simpler port like 8888 (no potential conflicts)?
2. **Nostr key handling:** Should the web page use a browser extension (nos2x/Alby) for signing, or accept an nsec directly? Extension is more secure but requires the user to have it installed.
3. **Relay location:** Is the relay on the same machine as the web page (localhost demo) or a remote server? This affects the bridge-to-UDP path.
4. **Persistence:** Should events persist across relay restarts (simple JSON file or SQLite) or is in-memory sufficient for the demo?