Skip to content

Commit acf4ce5

Browse files
authored
Merge branch 'main' into fix/nip01-replaceable-tiebreaker
2 parents e2fd5b6 + 78facd1 commit acf4ce5

18 files changed

Lines changed: 601 additions & 20 deletions

File tree

.env.example

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# --- REQUIRED ---
2+
SECRET=change_me_to_something_long_and_random # Generate: openssl rand -hex 128
3+
4+
# --- POSTGRESQL ---
5+
DB_HOST=localhost
6+
DB_PORT=5432
7+
DB_NAME=nostr_ts_relay
8+
DB_USER=nostr_ts_relay
9+
DB_PASSWORD=nostr_ts_relay
10+
# Alternatively, use a URI:
11+
# DB_URI=postgresql://nostr_ts_relay:nostr_ts_relay@localhost:5432/nostr_ts_relay
12+
13+
# --- DB POOL TUNING (Optional) ---
14+
# DB_MIN_POOL_SIZE=0
15+
# DB_MAX_POOL_SIZE=3
16+
# DB_ACQUIRE_CONNECTION_TIMEOUT=60000
17+
18+
# --- REDIS (Required for Rate Limiting) ---
19+
REDIS_HOST=localhost
20+
REDIS_PORT=6379
21+
# REDIS_USER=default
22+
# REDIS_PASSWORD=
23+
# Alternatively, use a URI:
24+
# REDIS_URI=redis://localhost:6379
25+
26+
# --- SERVER CONFIG ---
27+
RELAY_PORT=8008
28+
WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing.
29+
# NOSTR_CONFIG_DIR=.nostr # Where settings.yaml lives
30+
31+
# --- DEBUGGING ---
32+
# Useful namespaces: maintenance-worker, database-client:*, cache-client, etc.
33+
# DEBUG=maintenance-worker
34+
35+
# --- RELAY PRIVATE KEY (Optional) ---
36+
# RELAY_PRIVATE_KEY=your_hex_private_key
37+
38+
# --- PAYMENTS (Only if enabled in settings.yaml) ---
39+
# ZEBEDEE_API_KEY=
40+
# NODELESS_API_KEY=
41+
# NODELESS_WEBHOOK_SECRET=
42+
# OPENNODE_API_KEY=
43+
# LNBITS_API_KEY=
44+
45+
# --- READ REPLICAS (Optional) ---
46+
# READ_REPLICA_ENABLED=false
47+
# READ_REPLICAS=2
48+
# RR0_DB_HOST=localhost
49+
# RR0_DB_PORT=5432
50+
# RR0_DB_NAME=nostr_ts_relay
51+
# RR0_DB_USER=your_psql_username
52+
# RR0_DB_PASSWORD=your_psql_password
53+
# RR0_DB_MIN_POOL_SIZE=0
54+
# RR0_DB_MAX_POOL_SIZE=3
55+
# RR0_DB_ACQUIRE_CONNECTION_TIMEOUT=60000
56+
# RR1_DB_HOST=localhost
57+
# RR1_DB_PORT=5432
58+
# RR1_DB_NAME=nostr_ts_relay
59+
# RR1_DB_USER=your_psql_username
60+
# RR1_DB_PASSWORD=your_psql_password
61+
# RR1_DB_MIN_POOL_SIZE=0
62+
# RR1_DB_MAX_POOL_SIZE=3
63+
# RR1_DB_ACQUIRE_CONNECTION_TIMEOUT=60000
64+
65+
# --- TOR (Optional) ---
66+
# TOR_HOST=localhost
67+
# TOR_CONTROL_PORT=9051
68+
# TOR_PASSWORD=
69+
# HIDDEN_SERVICE_PORT=80

.github/workflows/checks.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ jobs:
7777
- name: Run coverage for unit tests
7878
run: npm run cover:unit
7979
if: ${{ always() }}
80-
- uses: actions/upload-artifact@v3
80+
- uses: actions/upload-artifact@v4
8181
name: Upload coverage report for unit tests
8282
if: ${{ always() }}
8383
with:
84-
path: .coverage/*/lcov.info
84+
name: unit-coverage-lcov
85+
path: .coverage/unit/lcov.info
8586
- name: Coveralls
8687
uses: coverallsapp/github-action@master
8788
if: ${{ always() }}
@@ -122,21 +123,23 @@ jobs:
122123
flag-name: Integration
123124
parallel: true
124125
github-token: ${{ secrets.GITHUB_TOKEN }}
125-
- uses: actions/upload-artifact@v3
126+
- uses: actions/upload-artifact@v4
126127
name: Upload coverage report for integration tests
127128
if: ${{ always() }}
128129
with:
129-
path: .coverage/*/lcov.info
130+
name: integration-coverage-lcov
131+
path: .coverage/integration/lcov.info
130132
sonarcloud:
131133
name: Sonarcloud
132134
needs: [test-units-and-cover, test-integrations-and-cover]
135+
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
133136
runs-on: ubuntu-latest
134137
steps:
135138
- name: Checkout
136139
uses: actions/checkout@v3
137140
with:
138141
fetch-depth: 0
139-
- uses: actions/download-artifact@v3
142+
- uses: actions/download-artifact@v4
140143
name: Download unit & integration coverage reports
141144
with:
142145
path: .coverage

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ node_modules/
2525
.dccache
2626
.DS_Store
2727

28+
# Local Setup
29+
redis.conf
30+
users.acl
31+
postgresql.local.conf
32+
2833
# generate output
2934
dist
3035

docs/REDIS.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Redis
2+
3+
Nostream uses Redis as a cache layer, currently for rate limiting incoming requests. This document covers how to configure Redis to work with Nostream, including the recommended ACL-based setup for production environments.
4+
5+
## Overview
6+
7+
Nostream uses Redis 7.0.5 (Alpine 3.16) and connects to it via the `redis` npm package (v4.5.1). Currently, Redis is used exclusively for rate limiting — throttling incoming requests from clients to prevent abuse.
8+
9+
The Redis client is initialized as a singleton instance in `src/cache/client.ts`, meaning a single connection is shared across the entire application. This connection is wrapped by a `RedisAdapter` which exposes only the specific Redis operations
10+
Nostream needs.
11+
12+
Rate limiting is implemented using a sliding window strategy, which uses Redis sorted sets to track request timestamps. This allows Nostream to accurately enforce rate limits over a rolling time window rather than a fixed one, preventing clients from bursting requests at window boundaries.
13+
14+
## Requirements
15+
16+
- Redis 6.0 or higher (for ACL support)
17+
- Nostream ships with Redis 7.0.5 (Alpine) by default via Docker Compose
18+
19+
If you are using your own Redis instance instead of the one provided by Docker Compose, ensure it is running Redis 6.0 or higher to take advantage of the ACL configuration described in this document.
20+
21+
## Configuration
22+
23+
### Default Setup
24+
25+
By default, Nostream connects to Redis using a single password on the default user via the `--requirepass` flag. This is configured in `docker-compose.yml`:
26+
27+
```yaml
28+
command: redis-server --loglevel warning --requirepass nostr_ts_relay
29+
```
30+
31+
While this works, it grants the connecting user full access to all Redis commands which is not recommended for production environments.
32+
33+
Nostream reads the Redis connection details from the following environment variables:
34+
35+
```
36+
REDIS_URI=redis://default:nostr_ts_relay@localhost:6379
37+
38+
# or individually:
39+
REDIS_HOST=localhost
40+
REDIS_PORT=6379
41+
REDIS_USER=default
42+
REDIS_PASSWORD=nostr_ts_relay
43+
```
44+
45+
### ACL Setup (Recommended)
46+
47+
Redis ACL (Access Control List), introduced in Redis 6.0, allows you to create restricted users that can only execute specific commands. This is recommended for production environments as it follows the principle of least privilege — Nostream only gets access to the commands it actually needs.
48+
49+
#### Required Commands
50+
51+
Nostream uses the following Redis commands internally:
52+
53+
| Command | Used For |
54+
|---|---|
55+
| `EXISTS` | Checking if a rate limit key exists |
56+
| `GET` | Retrieving a cached value |
57+
| `SET` | Storing a cached value |
58+
| `ZADD` | Adding a request timestamp to the sliding window |
59+
| `ZRANGE` | Reading request timestamps from the sliding window |
60+
| `ZREMRANGEBYSCORE` | Removing expired timestamps from the sliding window |
61+
| `EXPIRE` | Setting expiry on rate limit keys |
62+
63+
#### Example Configuration
64+
65+
**Using redis.conf:**
66+
67+
Add the following to your `redis.conf`:
68+
69+
```conf
70+
aclfile /etc/redis/users.acl
71+
```
72+
73+
Then create `/etc/redis/users.acl` with the following:
74+
75+
```
76+
user nostream on >your_password ~* &* +EXISTS +GET +SET +ZADD +ZRANGE +ZREMRANGEBYSCORE +EXPIRE
77+
```
78+
79+
**Using redis-cli:**
80+
81+
You can also set the ACL rule directly via `redis-cli`:
82+
83+
```bash
84+
ACL SETUSER nostream on >your_password ~* &* +EXISTS +GET +SET +ZADD +ZRANGE +ZREMRANGEBYSCORE +EXPIRE
85+
```
86+
87+
Verify the user was created correctly:
88+
89+
```bash
90+
ACL GETUSER nostream
91+
```
92+
93+
**Updating docker-compose.yml:**
94+
95+
Replace the default `--requirepass` flag with the ACL file approach:
96+
97+
```yaml
98+
nostream-cache:
99+
image: redis:7.0.5-alpine3.16
100+
container_name: nostream-cache
101+
volumes:
102+
- cache:/data
103+
- ./redis.conf:/usr/local/etc/redis/redis.conf
104+
- ./users.acl:/etc/redis/users.acl
105+
command: redis-server /usr/local/etc/redis/redis.conf
106+
networks:
107+
default:
108+
restart: always
109+
healthcheck:
110+
test: [ "CMD", "redis-cli", "-u", "redis://nostream:your_password@localhost:6379", "ping" ]
111+
interval: 1s
112+
timeout: 5s
113+
retries: 5
114+
```
115+
116+
Then update your `.env` file:
117+
118+
```
119+
REDIS_URI=redis://nostream:your_password@localhost:6379
120+
```
121+
122+
## Troubleshooting
123+
124+
**NOAUTH Authentication required**
125+
126+
Redis is requiring authentication but no password was provided. Ensure your `REDIS_URI` or `REDIS_PASSWORD` environment variables are set correctly.
127+
128+
**WRONGPASS invalid username-password pair**
129+
130+
The username or password provided is incorrect. Double check your `REDIS_USER` and `REDIS_PASSWORD` environment variables match what was configured in your ACL setup.
131+
132+
**NOPERM this user has no permissions to run the command**
133+
134+
The Redis user does not have permission to run a specific command. Ensure all 7 required commands are granted in your ACL rule:
135+
136+
```
137+
+EXISTS +GET +SET +ZADD +ZRANGE +ZREMRANGEBYSCORE +EXPIRE
138+
```
139+
140+
**Connection refused (ECONNREFUSED)**
141+
142+
Redis is not running or is not reachable at the configured host and port. Verify:
143+
- Redis is running (`docker ps` if using Docker)
144+
- `REDIS_HOST` and `REDIS_PORT` are correct
145+
- No firewall is blocking the connection

scripts/start

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,45 @@ if [ "$EUID" -eq 0 ]
1919
exit 1
2020
fi
2121

22+
# ── DNS Pre-flight Check ─────────────────────────────────────────────
23+
YELLOW=$'\033[0;33m'
24+
BOLD_YELLOW=$'\033[1;33m'
25+
NC=$'\033[0m'
26+
DNS_TEST_URL="https://dl-cdn.alpinelinux.org"
27+
DNS_MAX_RETRIES=3
28+
DNS_OK=false
29+
BACKOFF=2
30+
31+
echo "Checking Docker DNS connectivity..."
32+
for i in $(seq 1 $DNS_MAX_RETRIES); do
33+
printf " [Attempt $i/$DNS_MAX_RETRIES] Testing resolution... "
34+
if docker run --rm alpine wget --spider --timeout=5 "$DNS_TEST_URL" > /dev/null 2>&1; then
35+
echo "Success"
36+
DNS_OK=true
37+
break
38+
else
39+
echo "Failed"
40+
fi
41+
[ "$i" -lt "$DNS_MAX_RETRIES" ] && sleep $BACKOFF && BACKOFF=$((BACKOFF * 2))
42+
done
43+
44+
if [ "$DNS_OK" = false ]; then
45+
cat <<EOF >&2
46+
47+
${BOLD_YELLOW} WARNING: Docker DNS resolution failed after $DNS_MAX_RETRIES attempts.${NC}
48+
${YELLOW} Containers cannot resolve external domains (e.g. dl-cdn.alpinelinux.org).
49+
This is commonly caused by a DNS bridge conflict with systemd-resolved.
50+
51+
Suggested fixes:
52+
1. Add DNS to /etc/docker/daemon.json:
53+
{ "dns": ["8.8.8.8", "8.8.4.4"] }
54+
2. Then run sudo systemctl restart docker
55+
56+
The build will continue, but may fail during package installation.${NC}
57+
58+
EOF
59+
fi
60+
2261
if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then
2362
echo "Creating folder ${NOSTR_CONFIG_DIR}"
2463
mkdir -p "${NOSTR_CONFIG_DIR}"

src/@types/repositories.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface IEventRepository {
1717
upsert(event: Event): Promise<number>
1818
findByFilters(filters: SubscriptionFilter[]): IQueryResult<DBEvent[]>
1919
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
20+
deleteByPubkeyExceptKinds(pubkey: Pubkey, excludedKinds: number[]): Promise<number>
21+
hasActiveRequestToVanish(pubkey: Pubkey): Promise<boolean>
2022
}
2123

2224
export interface IInvoiceRepository {

src/constants/base.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum EventKinds {
77
DELETE = 5,
88
REPOST = 6,
99
REACTION = 7,
10+
REQUEST_TO_VANISH = 62,
1011
// Channels
1112
CHANNEL_CREATION = 40,
1213
CHANNEL_METADATA = 41,
@@ -36,12 +37,15 @@ export enum EventKinds {
3637
export enum EventTags {
3738
Event = 'e',
3839
Pubkey = 'p',
40+
Relay = 'r',
3941
// Multicast = 'm',
4042
Deduplication = 'd',
4143
Expiration = 'expiration',
4244
Invoice = 'bolt11',
4345
}
4446

47+
export const ALL_RELAYS = 'ALL_RELAYS'
48+
4549
export enum PaymentsProcessors {
4650
LNURL = 'lnurl',
4751
ZEBEDEE = 'zebedee',

src/factories/event-strategy-factory.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isDeleteEvent, isEphemeralEvent, isParameterizedReplaceableEvent, isReplaceableEvent } from '../utils/event'
1+
import { isDeleteEvent, isEphemeralEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent } from '../utils/event'
22
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
33
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
44
import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy'
@@ -9,12 +9,15 @@ import { IEventStrategy } from '../@types/message-handlers'
99
import { IWebSocketAdapter } from '../@types/adapters'
1010
import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy'
1111
import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy'
12+
import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-strategy'
1213

1314
export const eventStrategyFactory = (
1415
eventRepository: IEventRepository,
1516
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
1617
([event, adapter]: [Event, IWebSocketAdapter]) => {
17-
if (isReplaceableEvent(event)) {
18+
if (isRequestToVanishEvent(event)) {
19+
return new VanishEventStrategy(adapter, eventRepository)
20+
} else if (isReplaceableEvent(event)) {
1821
return new ReplaceableEventStrategy(adapter, eventRepository)
1922
} else if (isEphemeralEvent(event)) {
2023
return new EphemeralEventStrategy(adapter)

src/factories/message-handler-factory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const messageHandlerFactory = (
1818
return new EventMessageHandler(
1919
adapter,
2020
eventStrategyFactory(eventRepository),
21+
eventRepository,
2122
userRepository,
2223
createSettings,
2324
slidingWindowRateLimiterFactory,

0 commit comments

Comments
 (0)