Cold-cache latency fix for /api/score. The first (cache-miss) request for a
relay recomputed its score on-demand and blocked the response for ~14s on busy
relays, tripping typical client HTTP timeouts; repeat requests were fast. The
daemon already computes every scorable relay's score each cycle, so the endpoint
now serves that snapshot instead of recomputing.
Algorithm version unchanged (
v0.3.0): scoring math does not change, and published kind-30385 scores do not move./api/scorereadouts now match the dashboard's canonical (90-day NIP-66 window) score rather than the old on-demand 365-day-window recompute, so a relay's reported score may shift a point or two — a consistency fix, not a re-scoring.
GET /api/scores?url=…&url=…— batch endpoint returning many relay scores in one request (up to 100), so clients don't fan out N parallel calls on startup. Served from the in-memory snapshot and never blocks: a relay not yet in the snapshot returns{url, status: "pending"}(re-query/api/scorefor it), and an invalid URL returns{url, status: "invalid"}without failing the whole batch.
/api/scoreserves the precomputed snapshot. Known/scorable relays now return in ~0.2s on a cold cache instead of ~14s. The on-demand path remains only as a fallback for relays not in the snapshot (non-scorable / never-seen), where it is cheap. Request/response shape is unchanged — existing callers need no changes and can drop any inflated client timeouts.
Dead-relay curation. The relay set had accumulated ~1,200 relays that have never once responded (pulled in from NIP-66 monitor lists and re-probed every cycle), all sitting at the scorer's offline floor and cluttering the dashboard.
Algorithm version unchanged (
v0.3.0): scores for live relays do not move.
- Liveness-gated scoring/display/publishing. A relay is "scorable" only if it
had ≥1 successful probe in the last 30 days. Never-online / long-dead relays now
get no score (
status: offline) and are excluded from the default/api/relays, from rankings, and from published assertions. They auto-revive on the next successful probe. Relays merely down for a few days keep their score (the outage shows up as reduced reliability), so normal downtime is unaffected. - Two-tier probing. Relays with no success in
probing.demoteAfterDays(7) are demoted from every-cycle probing to a once-per-probing.revivalIntervalHours(24h) revival check — lighter, faster cycles, while still catching recoveries within a day.
?includeOffline=trueon/api/relaysto include offline relays.- Config:
probing.demoteAfterDays,probing.revivalIntervalHours.
The scoring algorithm version is unchanged (
v0.3.0) — this release is a datastore migration plus security/performance hardening, with no changes to scoring math, so published kind-30385 scores do not move.
- Migrated the data store from DuckDB to SQLite (
bun:sqlite). A corrupted DuckDB ART index ("Invalid node type for DeleteChild") was escalating into a hard Bun segfault crash-loop via theduckdbnative binding. DuckDB is an analytical engine that fit this workload (continuous single-writer ingestion + periodic analytics on a small host) poorly.bun:sqliteis built into Bun, so there's no native addon — eliminating that crash class — with a much smaller memory footprint (~1.2 GB → ~0.5 GB RSS) and WAL-based crash recovery.- All
DataStoremethod signatures are unchanged (an async shim wraps the synchronousbun:sqliteAPI), so callers were untouched. - Statistical aggregates with no SQLite equivalent were reimplemented:
STDDEV_SAMP/REGR_SLOPEas closed-form SQL,MEDIAN/QUANTILE_CONTin JS over the (cached) network-stats datasets. - Default database path is now
./data/trustedrelays.sqlite.
- All
- Per-table data retention + incremental auto-vacuum. Retention is now
configurable per table (
database.retentionDaysfor score_history/reports,database.probeRetentionDays,database.nip66RetentionDays); the DB usesauto_vacuum=INCREMENTALso cleanup reclaims space without a multi-second full-VACUUM stall.
- Capped WebSocket frame size (
maxPayload512 KB) on every relay connection — a hostile relay can no longer send a ~100 MiB frame to OOM the host. - SSRF hardening of the probe & operator-resolution paths. Relay URLs from
untrusted events are now host-gated (
isBlockedHost) and resolve-checked (DNS-rebinding) before any HTTP/WebSocket;.well-known/nostr.jsonfetches are redirect-pinned, size-capped, and content-type-checked. - Monitor enrollment is gated. kind-10166 announcements are signature-verified
and a new monitor is auto-trusted only when corroborated by ≥2 source relays
(
sources.minMonitorSources), up tosources.maxMonitors— resisting Sybil enrollment that would skew scores. - API hardening.
/api/untrackrequires an admin token (TRUSTEDRELAYS_ADMIN_TOKEN);/api/trackis behind the strict rate limiter;/api/metricsis admin/loopback-gated and rate-limited; mutating endpoints reject cross-origin requests (CSRF); CSV export neutralizes formula injection; GeoIP lookups skip private IPs; config load warns on world-readable perms.
- Read models are precomputed off the request path. The daemon computes the
relay list, rankings and network-stats snapshots once per cycle;
/api/relays,/api/rankingsand/api/network/statsserve them from memory (the ~16 s cold rebuild that blocked the event loop is gone). - Analytics queries rewritten from
ROW_NUMBER()full-table window scans toGROUP BY MAX(timestamp)covering-index joins, plus composite indexes onscore_historyandnip66_metrics(multi-second queries → sub-second). - Continuous probe/WoT worker pool replaces fixed-batch barriers, so one slow relay no longer idles the other concurrency slots.
- Eliminated N+1 query patterns in the publish cycle (bulk
getAllProbes/getAllReports) and removed redundant per-event ingestion writes.
- The
duckdb-asyncdependency and the one-time DuckDB→SQLite conversion script (migration complete).
0.2.0 - 2026-06-07
First versioned release. Includes a full security/correctness/performance audit, a critical production database repair, and the introduction of changelog + single-source versioning.
⚠️ Published scores change in this release. The scoring adjustments below shift the values in published kind-30385 assertions, so the algorithm version is bumped tov0.3.0(previously advertised inconsistently asv0.1.1/v0.1.2/v0.2.0). Consumers should expect score movement for some relays.
- Fixed stored XSS across the dashboard and network pages.
escHtmlnow escapes quotes (making it attribute-safe), the brokenescAttrwas replaced with a correct encoder, and all relay-controlled fields (name, software, operator pubkey, policy, country names, etc.) are now escaped at every interpolation point, including Leaflet map popups. - Fixed rate-limiter bypass / shared bucket. Client IP is now taken from the
trusted socket address (
server.requestIP); proxy headers (cf-connecting-ip/x-forwarded-for) are only honored when the newapi.trustProxysetting (or--trust-proxyflag) is enabled. Previously all non-Cloudflare clients shared one'unknown'bucket and the header was spoofable. - Blocked SSRF via user-submitted relay URLs.
/api/trackand the prober now reject loopback / private / link-local / cloud-metadata hosts, and the NIP-11 fetch no longer follows redirects, caps the body at 256 KB, and requires a JSON content type. - Capped abuse of write/compute endpoints.
/api/tracknow enforcestargets.maxRelays;/api/network/statsclamps theperiodparameter to an allow-list to prevent cache-busting amplification. - Bounded untrusted relay input. The WoT client now sets a query
limitand hard-caps buffered assertions to prevent memory/CPU exhaustion from a hostile relay. - Hardened secret handling.
config showredacts the private key, written config files arechmod 600, andloadConfigfails fast on malformed config instead of silently falling back to defaults (which had publishing enabled). - Other hardening. Added Subresource Integrity to the Leaflet CDN tags,
X-Content-Type-Options: nosniff/Referrer-Policyto API JSON responses, and secp256k1 scalar-range validation for private keys.
- Critical: repaired missing database primary keys. On databases created
under an older schema,
probes,nip66_metrics,operators,relay_reports, andscore_historywere missing their declared PRIMARY KEY (whichCREATE TABLE IF NOT EXISTScannot retrofit). This silently broke everyON CONFLICTupsert on those tables. A new repair migration deduplicates and adds the missing unique indexes. This restored NIP-66 metric ingestion and operator-trust persistence, which had been silently failing (on the production instance, for ~88 days). - Fixed silent failure of schema migrations. Migrations that recreate tables now run inside transactions (atomic) and no longer swallow real errors, and they preserve primary keys.
- Fixed a
readScorecomputation bug (mismatched sample filters plus an operator-precedence issue that dropped legitimate zero averages). - Fixed an unhandled promise rejection on database initialization that could crash startup; the original error is now surfaced.
- Fixed
updateMonitorStatsto upsert, so monitors are actually recorded; unified the single-relay and bulk NIP-66 stat queries so the detail and list views no longer disagree. - Fixed a time-of-check/time-of-use double-count in report ingestion.
- Fixed WebSocket/socket and listener leaks in the prober (added a close handler and unified teardown), a relay-pool reconnect storm (backoff no longer resets on every brief reconnect), and added a proactive per-minute publish rate cap.
- Fixed overlapping daemon cycles: the probe/publish and checkpoint loops are now self-rescheduling with re-entrancy guards and per-iteration error handling, so a slow or failing run can't stack or kill the loop.
- Fixed
mergeConfigsilently dropping theprobingconfig block. - Fixed Tor/I2P network misclassification (previously overloaded country code
XX); network type is now derived from the relay URL. - Replaced a non-standard
INSERT OR REPLACEwithON CONFLICTfor the network stats cache. - Added global
unhandledRejection/uncaughtExceptionhandlers to the daemon for graceful shutdown instead of silent death.
- Scoring (affects published values — see algorithm
v0.3.0):- Unlisted countries are now treated as "unknown" (jurisdiction score 75) instead of an invented default of 65, so an unlisted country is no longer scored more leniently than a truly-unknown one.
- The monitor-diversity bonus is now capped at 2.8 (diminishing returns).
- The single-relay and bulk NIP-66 latency-percentile formulas were unified so the detail and list views report identical scores.
- Static files are cached in memory; API JSON responses are no longer pretty-printed (smaller payloads on hot paths).
- The cleanup pass now runs
VACUUMto reclaim disk space. - Consolidated the algorithm version into a single source (
src/version.ts); the dashboard, config defaults, assertions, andALGORITHM.mdnow all agree.
CHANGELOG.md(this file) andsrc/version.tsas the single source of truth for versions.api.trustProxyconfig option and--trust-proxyCLI flag.
ALGORITHM.mdnow describes the displayed-avgLatencyMssource blend (probe vs. monitor, distinct from the connect/read percentile weighting), corrects the proof-of-work penalty range, notes themonitorBonuscap, and flags a known limitation: NIP-66-only relays default to 95 uptime (possible survivorship bias), left unchanged pending confirmation that monitors emit downtime events.
- Initial implementation: NIP-XX relay trust assertions (kind 30385) combining direct probing, NIP-66 monitor data, user reports, and operator verification; reliability/quality/accessibility scoring; REST API and web dashboard; network monitoring page.