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' ;
911import { connect } from './db.js' ;
1012import { ensureRelayDirectoryIndex , safeRelayUrl } from './hoses/relays.js' ;
13+ import { eventId } from './hoses/event.js' ;
1114import 'dotenv/config' ;
1215
1316const 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. */
107127export 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). */
180227export 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 ) ;
0 commit comments