Skip to content

Commit 3a9460d

Browse files
Merge pull request #38 from JavaScriptSolidServer/issue-37-writeability
Relay writeability: NIP-11 (pow/restricted) + ephemeral publish-test
2 parents 329360a + 2e02363 commit 3a9460d

4 files changed

Lines changed: 100 additions & 23 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ PORT=3000
2828
# PROBE_CONCURRENCY=25 # relays probed in parallel
2929
# PROBE_TIMEOUT=7000 # per-relay connect/HTTP timeout in ms
3030
# PROBE_MAX=1000 # cap relays dialed per sweep
31+
# PROBE_PRIVKEY=<64-hex> # THROWAWAY key (not your identity) — enables the
32+
# # ephemeral write-test (acceptsEvents + reason).
33+
# # Write-test is OFF unless this is set.
3134
# RUN_ONCE=1 # single sweep then exit (or pass --once)

src/db.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,12 @@ const RELAY_FIELDS = {
120120
const RELAY_FILTERS = {
121121
all: {},
122122
online: { online: true },
123-
// "public" = reachable, accepts events, and open to anyone (no pay/auth).
124-
public: { online: true, acceptsEvents: true, requiresPayment: { $ne: true }, requiresAuth: { $ne: true } },
123+
// "public" = reachable, accepts events, open to anyone (no pay/auth/pow/restricted).
124+
public: {
125+
online: true, acceptsEvents: true,
126+
requiresPayment: { $ne: true }, requiresAuth: { $ne: true },
127+
requiresPow: { $ne: true }, restrictedWrites: { $ne: true },
128+
},
125129
paid: { requiresPayment: true },
126130
auth: { requiresAuth: true },
127131
};

src/prober.js

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,30 @@
66
//
77
// The relay universe is the `relays` collection itself, continuously seeded by
88
// the follows/relay-lists hoses' URL harvesting.
9+
import { schnorr } from '@noble/curves/secp256k1.js';
10+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
911
import { connect } from './db.js';
1012
import { ensureRelayDirectoryIndex, safeRelayUrl } from './hoses/relays.js';
13+
import { eventId } from './hoses/event.js';
1114
import 'dotenv/config';
1215

1316
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
17+
const PROBE_KIND = 20000; // NIP-16 ephemeral: relays relay it but never store it
18+
19+
// Build a signed ephemeral event for the write-test, from a throwaway key.
20+
export function buildTestEvent(privHex, nowMs) {
21+
const priv = hexToBytes(privHex);
22+
const e = { pubkey: bytesToHex(schnorr.getPublicKey(priv)), created_at: Math.floor((nowMs || Date.now()) / 1000), kind: PROBE_KIND, tags: [], content: '' };
23+
const id = eventId(e);
24+
return { ...e, id, sig: bytesToHex(schnorr.sign(hexToBytes(id), priv)) };
25+
}
26+
27+
// Machine-readable category from a relay's OK message (NIP-01 prefixes:
28+
// pow / auth-required / payment-required / restricted / rate-limited / blocked…).
29+
export function reasonCat(msg) {
30+
const m = String(msg || '').match(/^\s*([a-z][a-z-]*):/i);
31+
return m ? m[1].toLowerCase() : (String(msg || '').trim() ? 'rejected' : '');
32+
}
1433

1534
// SECURITY: the prober dials ONLY this curated allowlist — never the harvested
1635
// `relays` directory, which is attacker-influenceable (anyone can publish a
@@ -87,6 +106,7 @@ export function proberConfig(env = process.env, argv = process.argv.slice(2)) {
87106
concurrency: flags.concurrency ?? pos(env.PROBE_CONCURRENCY, 25),
88107
timeout: flags.timeout ?? pos(env.PROBE_TIMEOUT, 7_000),
89108
cap: flags.max ?? pos(env.PROBE_MAX, 1_000),
109+
privkey: env.PROBE_PRIVKEY || null, // throwaway key enables the write-test; off if unset
90110
once: flags.once || env.RUN_ONCE === '1' || env.RUN_ONCE === 'true',
91111
help: !!flags.help,
92112
};
@@ -106,19 +126,45 @@ export function relayInfoUrl(wsUrl) {
106126
/** Extract the health-relevant flags from a NIP-11 relay info document. */
107127
export function nip11Flags(info) {
108128
const lim = (info && typeof info === 'object' && info.limitation) || {};
109-
return { requiresAuth: !!lim.auth_required, requiresPayment: !!lim.payment_required };
129+
return {
130+
requiresAuth: !!lim.auth_required,
131+
requiresPayment: !!lim.payment_required,
132+
requiresPow: Number(lim.min_pow_difficulty) > 0,
133+
restrictedWrites: !!lim.restricted_writes,
134+
};
110135
}
111136

112-
/** Open a WebSocket; resolve { online, responseTime, error }. Never rejects. */
113-
export function checkRelay(url, timeoutMs) {
137+
/**
138+
* Open a WebSocket; resolve { online, responseTime, error }. If `testEvent` is
139+
* given, also publish it on open and capture the relay's OK → `publish:
140+
* {accepted, reason}` (ground-truth writeability). Never rejects.
141+
*/
142+
export function checkRelay(url, timeoutMs, testEvent = null) {
114143
return new Promise((resolve) => {
115144
const start = Date.now();
116-
let done = false, ws, timer;
145+
let done = false, ws, timer, openMs = null;
117146
const finish = (r) => { if (done) return; done = true; clearTimeout(timer); try { ws?.close(); } catch {} resolve(r); };
118-
timer = setTimeout(() => finish({ online: false, responseTime: null, error: 'timeout' }), timeoutMs);
147+
// On timeout: if we opened, the relay is online but didn't ack our event.
148+
timer = setTimeout(() => finish(openMs == null
149+
? { online: false, responseTime: null, error: 'timeout' }
150+
: { online: true, responseTime: openMs, error: null, publish: testEvent ? { accepted: false, reason: 'timeout' } : undefined }), timeoutMs);
119151
try { ws = new WebSocket(url); } catch (e) { return finish({ online: false, responseTime: null, error: String(e?.message || e) }); }
120-
ws.addEventListener('open', () => finish({ online: true, responseTime: Date.now() - start, error: null }));
121-
ws.addEventListener('error', (e) => finish({ online: false, responseTime: null, error: e?.message || 'error' }));
152+
ws.addEventListener('open', () => {
153+
openMs = Date.now() - start;
154+
if (testEvent) ws.send(JSON.stringify(['EVENT', testEvent])); // wait for OK
155+
else finish({ online: true, responseTime: openMs, error: null });
156+
});
157+
ws.addEventListener('message', (m) => {
158+
if (!testEvent) return;
159+
try {
160+
const d = JSON.parse(typeof m.data === 'string' ? m.data : m.data.toString());
161+
if (d[0] === 'OK' && d[1] === testEvent.id) finish({ online: true, responseTime: openMs, error: null, publish: { accepted: !!d[2], reason: d[2] ? '' : reasonCat(d[3]) } });
162+
else if (d[0] === 'AUTH') finish({ online: true, responseTime: openMs, error: null, publish: { accepted: false, reason: 'auth-required' } });
163+
} catch { /* ignore malformed frames */ }
164+
});
165+
ws.addEventListener('error', (e) => finish(openMs == null
166+
? { online: false, responseTime: null, error: e?.message || 'error' }
167+
: { online: true, responseTime: openMs, error: null, publish: testEvent ? { accepted: false, reason: 'error' } : undefined }));
122168
});
123169
}
124170

@@ -137,8 +183,8 @@ export async function fetchNip11(url, timeoutMs) {
137183
}
138184

139185
/** Probe one relay and persist the result (uptime rolled up from running totals). */
140-
export async function probeRelay(db, relay, cfg) {
141-
const conn = await checkRelay(relay, cfg.timeout);
186+
export async function probeRelay(db, relay, cfg, testEvent = null) {
187+
const conn = await checkRelay(relay, cfg.timeout, testEvent);
142188
const nip11 = conn.online ? await fetchNip11(relay, cfg.timeout) : null;
143189
await db.collection(RELAY_DIRECTORY).updateOne({ relay }, [
144190
{
@@ -147,7 +193,8 @@ export async function probeRelay(db, relay, cfg) {
147193
online: conn.online,
148194
responseTime: conn.responseTime,
149195
lastError: conn.error,
150-
...(nip11 ? { requiresAuth: nip11.requiresAuth, requiresPayment: nip11.requiresPayment } : {}),
196+
...(nip11 ? { requiresAuth: nip11.requiresAuth, requiresPayment: nip11.requiresPayment, requiresPow: nip11.requiresPow, restrictedWrites: nip11.restrictedWrites } : {}),
197+
...(conn.publish ? { acceptsEvents: conn.publish.accepted, publishReason: conn.publish.reason, lastPublishTest: '$$NOW' } : {}),
151198
checksTotal: { $add: [{ $ifNull: ['$checksTotal', 0] }, 1] },
152199
checksOnline: { $add: [{ $ifNull: ['$checksOnline', 0] }, conn.online ? 1 : 0] },
153200
},
@@ -179,12 +226,14 @@ async function sweepTargets(db, cfg) {
179226
/** One sweep over the allowlist + verified candidates (never new harvest). */
180227
export async function sweepOnce(db, cfg) {
181228
const relays = await sweepTargets(db, cfg);
229+
// One ephemeral test event per sweep (only if a throwaway key is configured).
230+
const testEvent = cfg.privkey ? buildTestEvent(cfg.privkey) : null;
182231
let online = 0;
183232
await mapPool(relays, cfg.concurrency, async (relay) => {
184-
try { if ((await probeRelay(db, relay, cfg)).online) online++; }
233+
try { if ((await probeRelay(db, relay, cfg, testEvent)).online) online++; }
185234
catch (e) { console.error(`[beacon] probe error ${relay}: ${e.message}`); }
186235
});
187-
console.log(`[beacon] relay sweep: ${online}/${relays.length} online (allowlist + verified, cap ${cfg.cap})`);
236+
console.log(`[beacon] relay sweep: ${online}/${relays.length} online (allowlist + verified, cap ${cfg.cap}${testEvent ? ', write-test on' : ''})`);
188237
return { total: relays.length, online };
189238
}
190239

@@ -193,7 +242,7 @@ export async function runProber() {
193242
const db = await connect();
194243
await ensureRelayDirectoryIndex(db);
195244
if (cfg.once) { await sweepOnce(db, cfg); return { stop() {} }; }
196-
console.log(`[beacon] relay-health prober: every ${Math.round(cfg.interval / 60000)}min, concurrency ${cfg.concurrency}, timeout ${cfg.timeout}ms`);
245+
console.log(`[beacon] relay-health prober: every ${Math.round(cfg.interval / 60000)}min, concurrency ${cfg.concurrency}, timeout ${cfg.timeout}ms, write-test ${cfg.privkey ? 'on' : 'off'}`);
197246
await sweepOnce(db, cfg);
198247
const timer = setInterval(() => sweepOnce(db, cfg).catch((e) => console.error('[beacon] sweep error:', e.message)), cfg.interval);
199248
const stop = () => clearInterval(timer);

test/prober.test.js

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
// manual smoke run, not here.
44
import test from 'node:test';
55
import assert from 'node:assert/strict';
6-
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags, proberRelays, DEFAULT_PROBE_RELAYS, selectTargets } from '../src/prober.js';
6+
import { schnorr } from '@noble/curves/secp256k1.js';
7+
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
8+
import { parseArgs, proberConfig, relayInfoUrl, nip11Flags, proberRelays, DEFAULT_PROBE_RELAYS, selectTargets, buildTestEvent, reasonCat } from '../src/prober.js';
9+
import { verifySignature } from '../src/hoses/event.js';
710

811
test('parseArgs: flags', () => {
912
assert.deepEqual(parseArgs(['--once']), { once: true });
@@ -72,11 +75,29 @@ test('relayInfoUrl: ws(s) -> http(s), else null', () => {
7275
assert.equal(relayInfoUrl('garbage'), null);
7376
});
7477

75-
test('nip11Flags: reads limitation flags, defaults false', () => {
76-
assert.deepEqual(nip11Flags({ limitation: { auth_required: true, payment_required: false } }),
77-
{ requiresAuth: true, requiresPayment: false });
78-
assert.deepEqual(nip11Flags({ limitation: { payment_required: true } }),
79-
{ requiresAuth: false, requiresPayment: true });
80-
assert.deepEqual(nip11Flags({}), { requiresAuth: false, requiresPayment: false });
81-
assert.deepEqual(nip11Flags(null), { requiresAuth: false, requiresPayment: false });
78+
test('nip11Flags: reads auth/payment/pow/restricted, defaults false', () => {
79+
assert.deepEqual(nip11Flags({ limitation: { auth_required: true, min_pow_difficulty: 28, restricted_writes: true } }),
80+
{ requiresAuth: true, requiresPayment: false, requiresPow: true, restrictedWrites: true });
81+
assert.deepEqual(nip11Flags({ limitation: { min_pow_difficulty: 0 } }),
82+
{ requiresAuth: false, requiresPayment: false, requiresPow: false, restrictedWrites: false });
83+
assert.deepEqual(nip11Flags(null),
84+
{ requiresAuth: false, requiresPayment: false, requiresPow: false, restrictedWrites: false });
85+
});
86+
87+
test('reasonCat: parses NIP-01 OK-message prefixes', () => {
88+
assert.equal(reasonCat('pow: 28 bits needed'), 'pow');
89+
assert.equal(reasonCat('auth-required: please AUTH'), 'auth-required');
90+
assert.equal(reasonCat('restricted: not in WoT'), 'restricted');
91+
assert.equal(reasonCat('blocked: spam'), 'blocked');
92+
assert.equal(reasonCat('rate-limited: slow down'), 'rate-limited');
93+
assert.equal(reasonCat(''), '');
94+
assert.equal(reasonCat('some freeform rejection'), 'rejected');
95+
});
96+
97+
test('buildTestEvent: a valid signed ephemeral event', () => {
98+
const priv = '0000000000000000000000000000000000000000000000000000000000000005';
99+
const e = buildTestEvent(priv, 1_700_000_000_000);
100+
assert.equal(e.kind, 20000); // NIP-16 ephemeral
101+
assert.equal(e.pubkey, bytesToHex(schnorr.getPublicKey(hexToBytes(priv))));
102+
assert.equal(verifySignature(e), true); // properly signed
82103
});

0 commit comments

Comments
 (0)