Browse Source
add default-ON option to always add three random public relays, when publishing add relay-type info to the relay selection box run a cronjob for relay monitoring on the serverimwald
26 changed files with 1404 additions and 73 deletions
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh |
||||
# Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client. |
||||
# Optional: NIP66_MONITOR_NPUB (npub of the monitor) can be exposed so the relay info page shows who runs the monitor. |
||||
if [ -n "$NIP66_MONITOR_NPUB" ]; then |
||||
echo "$NIP66_MONITOR_NPUB" | jq -n -R '{NIP66_MONITOR_NPUB: .}' > /usr/share/nginx/html/config.json |
||||
else |
||||
echo '{}' > /usr/share/nginx/html/config.json |
||||
fi |
||||
exec nginx -g "daemon off;" |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
# NIP-66 monitor – security audit (nsec handling) |
||||
|
||||
## Summary |
||||
|
||||
The monitor **nsec** (`NIP66_MONITOR_NSEC`) is used only in the **nip66-cron** container. It is **never** sent to the web app container, written to config.json, or exposed to the client. |
||||
|
||||
## Where the nsec may exist |
||||
|
||||
| Location | Allowed? | Notes | |
||||
|----------|----------|--------| |
||||
| **Host env** (e.g. `.env`) | ✅ | Operator sets it; not in repo. | |
||||
| **jumble-nip66-monitor container env** | ✅ | Only service that needs it. | |
||||
| **jumble container env** | ❌ | Removed: nsec is not passed to the web app. | |
||||
| **config.json** (served to browser) | ❌ | Entrypoint writes only `NIP66_MONITOR_NPUB` or `{}`; never nsec. | |
||||
| **Frontend (Window.__RUNTIME_CONFIG__)** | ❌ | Type and fetch only include `NIP66_MONITOR_NPUB`. | |
||||
| **Vite / build** | ❌ | No `VITE_NIP66_*` or nsec in bundle. | |
||||
|
||||
## Checks performed |
||||
|
||||
1. **docker-entrypoint.sh** – Writes config.json only from `NIP66_MONITOR_NPUB`; does not read or write `NIP66_MONITOR_NSEC`. |
||||
2. **docker-compose.prod.yml** – `NIP66_MONITOR_NSEC` is set only on the **jumble-nip66-monitor** service; **jumble** has only `NIP66_MONITOR_NPUB`. |
||||
3. **main.tsx** – Fetches config and types only `NIP66_MONITOR_NPUB`; no nsec in `Window.__RUNTIME_CONFIG__`. |
||||
4. **nip66-monitor.ts** (frontend) – Stub only; `getMonitorSecretKey()` always returns `null`; no env or config read for nsec. |
||||
5. **nip66-cron/index.mjs** – Reads nsec from `process.env.NIP66_MONITOR_NSEC` only; never logs it or passes it to `log()`; comment added to never log or expose it. |
||||
6. **RelayInfo / RelayLivelinessSection** – Use only `window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB` (npub) for display. |
||||
|
||||
## Recommendation |
||||
|
||||
- Keep **NIP66_MONITOR_NSEC** only in the host env and in the **jumble-nip66-monitor** service. |
||||
- Do not add nsec to the jumble service env, config.json, or any client-exposed config. |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
# NIP-66 monitor cron: runs alongside the app, keeps nsec on server only. |
||||
FROM node:20-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package.json ./ |
||||
RUN npm install --omit=dev |
||||
|
||||
COPY index.mjs ./ |
||||
|
||||
ENV NODE_ENV=production |
||||
CMD ["node", "index.mjs"] |
||||
@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
|
||||
/** |
||||
* NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client. |
||||
* - On startup: publish kind 10166 (monitor announcement) once. |
||||
* - Every INTERVAL_MS: for each relay in RELAYS_TO_MONITOR, fetch NIP-11, build & publish 30166. |
||||
* |
||||
* Env: |
||||
* NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166 |
||||
* RELAYS_TO_MONITOR - optional; comma-separated wss:// URLs. Default: built-in list.
|
||||
* PUBLISH_RELAYS - optional; comma-separated relays to publish to. Default: built-in list. |
||||
* INTERVAL_MS - optional; ms between full monitor runs (default 3600000 = 1h) |
||||
*/ |
||||
|
||||
import { finalizeEvent, nip19 } from 'nostr-tools' |
||||
import WebSocket from 'ws' |
||||
|
||||
const RELAY_DISCOVERY_KIND = 30166 |
||||
const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166 |
||||
|
||||
const DEFAULT_RELAYS_TO_MONITOR = [ |
||||
'wss://theforest.nostr1.com', |
||||
'wss://orly-relay.imwald.eu', |
||||
'wss://nostr.land', |
||||
'wss://thecitadel.nostr1.com', |
||||
'wss://relay.damus.io', |
||||
'wss://relay.primal.net', |
||||
'wss://nos.lol' |
||||
] |
||||
|
||||
const DEFAULT_PUBLISH_RELAYS = [ |
||||
'wss://theforest.nostr1.com', |
||||
'wss://thecitadel.nostr1.com', |
||||
'wss://relay.damus.io', |
||||
'wss://relay.nostr.watch' |
||||
] |
||||
|
||||
const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 3600000 // 1 hour
|
||||
|
||||
function log (msg, data = {}) { |
||||
const ts = new Date().toISOString() |
||||
console.log(ts, '[nip66-cron]', msg, Object.keys(data).length ? JSON.stringify(data) : '') |
||||
} |
||||
|
||||
function normalizeRelayUrl (url) { |
||||
try { |
||||
const u = url.replace(/^ws:\/\//, 'wss://') |
||||
const p = new URL(u.startsWith('wss://') ? u : `wss://${u}`) |
||||
p.pathname = p.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/' |
||||
return p.toString() |
||||
} catch { |
||||
return url |
||||
} |
||||
} |
||||
|
||||
/** Returns decoded secret key from env. Never log or expose process.env.NIP66_MONITOR_NSEC. */ |
||||
function getSecretKey () { |
||||
const raw = process.env.NIP66_MONITOR_NSEC |
||||
if (!raw || typeof raw !== 'string') return null |
||||
try { |
||||
const { type, data } = nip19.decode(raw) |
||||
if (type !== 'nsec') return null |
||||
return data |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
async function fetchNip11 (relayUrl) { |
||||
const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://') |
||||
try { |
||||
const res = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' } }) |
||||
if (!res.ok) return null |
||||
return await res.json() |
||||
} catch (err) { |
||||
log('NIP-11 fetch failed', { url: relayUrl, err: err.message }) |
||||
return null |
||||
} |
||||
} |
||||
|
||||
function build30166 (relayUrl, nip11, sk) { |
||||
const d = normalizeRelayUrl(relayUrl) |
||||
const tags = [['d', d]] |
||||
const nips = nip11?.supported_nips |
||||
if (Array.isArray(nips)) { |
||||
for (const n of nips) tags.push(['N', String(n)]) |
||||
} |
||||
const lim = nip11?.limitation |
||||
tags.push(['R', lim?.auth_required ? 'auth' : '!auth']) |
||||
tags.push(['R', lim?.payment_required ? 'payment' : '!payment']) |
||||
const draft = { |
||||
kind: RELAY_DISCOVERY_KIND, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags |
||||
} |
||||
return finalizeEvent(draft, sk) |
||||
} |
||||
|
||||
function build10166 (sk) { |
||||
const draft = { |
||||
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags: [['frequency', '3600'], ['c', 'nip11'], ['c', 'ws']] |
||||
} |
||||
return finalizeEvent(draft, sk) |
||||
} |
||||
|
||||
function parseListEnv (envVar, defaultList) { |
||||
const raw = process.env[envVar] |
||||
if (!raw || typeof raw !== 'string') return defaultList |
||||
return raw.split(',').map(s => s.trim()).filter(Boolean) |
||||
} |
||||
|
||||
async function publishEvent (relayUrls, event) { |
||||
const msg = JSON.stringify(['EVENT', event]) |
||||
let ok = 0 |
||||
const conns = [] |
||||
for (const url of relayUrls) { |
||||
try { |
||||
const ws = new WebSocket(url, { handshakeTimeout: 8000 }) |
||||
await new Promise((resolve, reject) => { |
||||
ws.on('open', resolve) |
||||
ws.on('error', reject) |
||||
setTimeout(() => reject(new Error('open timeout')), 10000) |
||||
}) |
||||
conns.push(ws) |
||||
ws.send(msg) |
||||
await new Promise((resolve) => { |
||||
const onResp = (data) => { |
||||
try { |
||||
const j = JSON.parse(data.toString()) |
||||
if (j[0] === 'OK' && j[1] === event.id) { |
||||
ok++ |
||||
if (j[2] === true) { /* accepted */ } else { log('Relay rejected event', { url, reason: j[2] }) } |
||||
} |
||||
} finally { |
||||
resolve() |
||||
} |
||||
} |
||||
ws.once('message', onResp) |
||||
setTimeout(resolve, 3000) |
||||
}) |
||||
} catch (err) { |
||||
log('Publish relay error', { url, err: err.message }) |
||||
} |
||||
} |
||||
for (const ws of conns) { |
||||
try { ws.close() } catch (_) {} |
||||
} |
||||
return ok |
||||
} |
||||
|
||||
async function run10166 (sk, publishRelays) { |
||||
const event = build10166(sk) |
||||
log('Publishing 10166 (monitor announcement)') |
||||
const count = await publishEvent(publishRelays, event) |
||||
log('Published 10166', { successCount: count }) |
||||
} |
||||
|
||||
async function run30166Round (sk, relaysToMonitor, publishRelays) { |
||||
for (const relayUrl of relaysToMonitor) { |
||||
const nip11 = await fetchNip11(relayUrl) |
||||
if (!nip11) continue |
||||
const event = build30166(relayUrl, nip11, sk) |
||||
const count = await publishEvent(publishRelays, event) |
||||
log('Published 30166', { url: relayUrl, successCount: count }) |
||||
} |
||||
} |
||||
|
||||
async function main () { |
||||
const sk = getSecretKey() |
||||
if (!sk) { |
||||
log('No NIP66_MONITOR_NSEC set; exiting') |
||||
process.exit(0) |
||||
} |
||||
log('NIP-66 monitor cron started (nsec configured)') |
||||
|
||||
const relaysToMonitor = parseListEnv('RELAYS_TO_MONITOR', DEFAULT_RELAYS_TO_MONITOR) |
||||
const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS) |
||||
|
||||
await run10166(sk, publishRelays) |
||||
|
||||
const run = () => run30166Round(sk, relaysToMonitor, publishRelays) |
||||
await run() |
||||
setInterval(run, INTERVAL_MS) |
||||
} |
||||
|
||||
main().catch((err) => { |
||||
console.error('[nip66-cron]', err) |
||||
process.exit(1) |
||||
}) |
||||
@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
|
||||
{ |
||||
"name": "jumble-nip66-cron", |
||||
"version": "1.0.0", |
||||
"lockfileVersion": 3, |
||||
"requires": true, |
||||
"packages": { |
||||
"": { |
||||
"name": "jumble-nip66-cron", |
||||
"version": "1.0.0", |
||||
"dependencies": { |
||||
"nostr-tools": "^2.17.0", |
||||
"ws": "^8.18.0" |
||||
} |
||||
}, |
||||
"node_modules/@noble/ciphers": { |
||||
"version": "2.1.1", |
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", |
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", |
||||
"license": "MIT", |
||||
"engines": { |
||||
"node": ">= 20.19.0" |
||||
}, |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/@noble/curves": { |
||||
"version": "2.0.1", |
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", |
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", |
||||
"license": "MIT", |
||||
"dependencies": { |
||||
"@noble/hashes": "2.0.1" |
||||
}, |
||||
"engines": { |
||||
"node": ">= 20.19.0" |
||||
}, |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/@noble/hashes": { |
||||
"version": "2.0.1", |
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", |
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", |
||||
"license": "MIT", |
||||
"engines": { |
||||
"node": ">= 20.19.0" |
||||
}, |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/@scure/base": { |
||||
"version": "2.0.0", |
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", |
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", |
||||
"license": "MIT", |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/@scure/bip32": { |
||||
"version": "2.0.1", |
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", |
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", |
||||
"license": "MIT", |
||||
"dependencies": { |
||||
"@noble/curves": "2.0.1", |
||||
"@noble/hashes": "2.0.1", |
||||
"@scure/base": "2.0.0" |
||||
}, |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/@scure/bip39": { |
||||
"version": "2.0.1", |
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", |
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", |
||||
"license": "MIT", |
||||
"dependencies": { |
||||
"@noble/hashes": "2.0.1", |
||||
"@scure/base": "2.0.0" |
||||
}, |
||||
"funding": { |
||||
"url": "https://paulmillr.com/funding/" |
||||
} |
||||
}, |
||||
"node_modules/nostr-tools": { |
||||
"version": "2.23.3", |
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz", |
||||
"integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", |
||||
"license": "Unlicense", |
||||
"dependencies": { |
||||
"@noble/ciphers": "2.1.1", |
||||
"@noble/curves": "2.0.1", |
||||
"@noble/hashes": "2.0.1", |
||||
"@scure/base": "2.0.0", |
||||
"@scure/bip32": "2.0.1", |
||||
"@scure/bip39": "2.0.1", |
||||
"nostr-wasm": "0.1.0" |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": ">=5.0.0" |
||||
}, |
||||
"peerDependenciesMeta": { |
||||
"typescript": { |
||||
"optional": true |
||||
} |
||||
} |
||||
}, |
||||
"node_modules/nostr-wasm": { |
||||
"version": "0.1.0", |
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", |
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", |
||||
"license": "MIT" |
||||
}, |
||||
"node_modules/ws": { |
||||
"version": "8.19.0", |
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", |
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", |
||||
"license": "MIT", |
||||
"engines": { |
||||
"node": ">=10.0.0" |
||||
}, |
||||
"peerDependencies": { |
||||
"bufferutil": "^4.0.1", |
||||
"utf-8-validate": ">=5.0.2" |
||||
}, |
||||
"peerDependenciesMeta": { |
||||
"bufferutil": { |
||||
"optional": true |
||||
}, |
||||
"utf-8-validate": { |
||||
"optional": true |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
{ |
||||
"name": "jumble-nip66-cron", |
||||
"version": "1.0.0", |
||||
"private": true, |
||||
"type": "module", |
||||
"description": "NIP-66 relay monitor cron: publishes 30166/10166 from server, nsec never exposed to client", |
||||
"scripts": { |
||||
"start": "node index.mjs" |
||||
}, |
||||
"dependencies": { |
||||
"nostr-tools": "^2.17.0", |
||||
"ws": "^8.18.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
/** |
||||
* NIP-66 relay monitor (client stub). |
||||
* Publishing 30166/10166 runs in the server cron only; this module only exposes isNip66MonitorEnabled() === false |
||||
* and no-op builders so relay-info and bootstrap can keep calling without branching. |
||||
*/ |
||||
|
||||
import { BIG_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { TRelayInfo } from '@/types' |
||||
import { Event as NEvent, finalizeEvent } from 'nostr-tools' |
||||
import { ExtendedKind } from '@/constants' |
||||
import logger from '@/lib/logger' |
||||
import client from '@/services/client.service' |
||||
|
||||
const RELAY_DISCOVERY_KIND = ExtendedKind.RELAY_DISCOVERY |
||||
const RELAY_MONITOR_ANNOUNCEMENT_KIND = ExtendedKind.RELAY_MONITOR_ANNOUNCEMENT |
||||
|
||||
let publishedAnnouncementThisSession = false |
||||
|
||||
function getMonitorSecretKey(): Uint8Array | null { |
||||
return null |
||||
} |
||||
|
||||
/** False in the client; publishing is done by the server cron. */ |
||||
export function isNip66MonitorEnabled(): boolean { |
||||
return getMonitorSecretKey() !== null |
||||
} |
||||
|
||||
/** |
||||
* Build and sign a kind 30166 relay discovery event from NIP-11–derived relay info. |
||||
* Returns null in the client (signing runs in the server cron). |
||||
*/ |
||||
export function buildAndSignDiscoveryEvent(relayInfo: TRelayInfo): NEvent | null { |
||||
const sk = getMonitorSecretKey() |
||||
if (!sk) return null |
||||
|
||||
const d = normalizeUrl(relayInfo.url) || relayInfo.url |
||||
const tags: string[][] = [['d', d]] |
||||
|
||||
if (Array.isArray(relayInfo.supported_nips)) { |
||||
for (const n of relayInfo.supported_nips) { |
||||
tags.push(['N', String(n)]) |
||||
} |
||||
} |
||||
|
||||
const lim = relayInfo.limitation |
||||
if (lim?.auth_required) tags.push(['R', 'auth']) |
||||
else tags.push(['R', '!auth']) |
||||
if (lim?.payment_required) tags.push(['R', 'payment']) |
||||
else tags.push(['R', '!payment']) |
||||
|
||||
const draft = { |
||||
kind: RELAY_DISCOVERY_KIND, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags |
||||
} |
||||
|
||||
try { |
||||
const event = finalizeEvent(draft, sk) |
||||
return event as NEvent |
||||
} catch (err) { |
||||
logger.warn('NIP-66 monitor: failed to sign 30166 event', { err, url: relayInfo.url }) |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Build and sign a kind 10166 relay monitor announcement. |
||||
* Returns null in the client (handled by server cron). |
||||
*/ |
||||
function buildAndSignMonitorAnnouncement(): NEvent | null { |
||||
const sk = getMonitorSecretKey() |
||||
if (!sk) return null |
||||
const draft = { |
||||
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags: [ |
||||
['frequency', '3600'], |
||||
['c', 'nip11'], |
||||
['c', 'ws'] |
||||
] |
||||
} |
||||
try { |
||||
return finalizeEvent(draft, sk) as NEvent |
||||
} catch (err) { |
||||
logger.warn('NIP-66 monitor: failed to sign 10166 event', { err }) |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** No-op in the client; 10166 is published by the server cron on startup. */ |
||||
export function publishMonitorAnnouncementOnce(): void { |
||||
if (publishedAnnouncementThisSession || !isNip66MonitorEnabled()) return |
||||
const event = buildAndSignMonitorAnnouncement() |
||||
if (!event) return |
||||
publishedAnnouncementThisSession = true |
||||
logger.info('NIP-66: publishing monitor announcement (10166)') |
||||
client.publishEvent([...BIG_RELAY_URLS.slice(0, 4)], event).then((res) => { |
||||
if (res.successCount > 0) { |
||||
logger.info('NIP-66: published monitor announcement (10166)', { successCount: res.successCount }) |
||||
} |
||||
}).catch((err) => { |
||||
logger.warn('NIP-66: publish monitor announcement failed', { err }) |
||||
}) |
||||
} |
||||
@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
|
||||
/** |
||||
* NIP-66 Relay Discovery and Liveness Monitoring (consumer side). |
||||
* |
||||
* Parses kind 30166 relay discovery events and exposes relay metadata (supported NIPs, |
||||
* requirements, RTT, etc.) to supplement NIP-11 and static relay lists. Clients MUST NOT |
||||
* require this data to function; use as a hint only. |
||||
*/ |
||||
|
||||
import { PUBLIC_LIVELY_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import indexDb from '@/services/indexed-db.service' |
||||
import { TNip66RelayDiscovery, TRelayInfo } from '@/types' |
||||
import { Event as NEvent } from 'nostr-tools' |
||||
|
||||
const RELAY_DISCOVERY_KIND = 30166 |
||||
|
||||
function parseRequirement(value: string): { key: string; required: boolean } { |
||||
const negated = value.startsWith('!') |
||||
return { key: negated ? value.slice(1) : value, required: !negated } |
||||
} |
||||
|
||||
function parseEvent(ev: NEvent): TNip66RelayDiscovery | null { |
||||
if (ev.kind !== RELAY_DISCOVERY_KIND) return null |
||||
const d = ev.tags.find((t) => t[0] === 'd')?.[1] |
||||
if (!d) return null |
||||
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}` |
||||
|
||||
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) |
||||
const requirements: TNip66RelayDiscovery['requirements'] = {} |
||||
for (const t of ev.tags.filter((t) => t[0] === 'R')) { |
||||
const { key, required } = parseRequirement(t[1] ?? '') |
||||
if (key === 'auth') requirements.auth = required |
||||
else if (key === 'payment') requirements.payment = required |
||||
else if (key === 'writes') requirements.writes = required |
||||
else if (key === 'pow') requirements.pow = required |
||||
} |
||||
|
||||
const rttOpen = ev.tags.find((t) => t[0] === 'rtt-open')?.[1] |
||||
const rttRead = ev.tags.find((t) => t[0] === 'rtt-read')?.[1] |
||||
const rttWrite = ev.tags.find((t) => t[0] === 'rtt-write')?.[1] |
||||
const networkType = ev.tags.find((t) => t[0] === 'n')?.[1] |
||||
const relayType = ev.tags.find((t) => t[0] === 'T')?.[1] |
||||
const topics = ev.tags.filter((t) => t[0] === 't').map((t) => t[1]).filter(Boolean) as string[] |
||||
|
||||
return { |
||||
url, |
||||
supportedNips: [...new Set(nips)], |
||||
requirements, |
||||
rttOpenMs: rttOpen != null ? parseInt(rttOpen, 10) : undefined, |
||||
rttReadMs: rttRead != null ? parseInt(rttRead, 10) : undefined, |
||||
rttWriteMs: rttWrite != null ? parseInt(rttWrite, 10) : undefined, |
||||
networkType, |
||||
relayType, |
||||
topics: topics.length ? topics : undefined, |
||||
created_at: ev.created_at, |
||||
monitorPubkey: ev.pubkey |
||||
} |
||||
} |
||||
|
||||
/** TTL for the IndexedDB cache of public lively relay list (7 days). */ |
||||
const PUBLIC_LIVELY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 |
||||
|
||||
/** TTL for per-relay NIP-66 discovery cache (24h). After this, we refetch from network. */ |
||||
const DISCOVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000 |
||||
|
||||
class Nip66Service { |
||||
private static instance: Nip66Service |
||||
/** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */ |
||||
private discoveryByUrl = new Map<string, TNip66RelayDiscovery>() |
||||
|
||||
static getInstance(): Nip66Service { |
||||
if (!Nip66Service.instance) { |
||||
Nip66Service.instance = new Nip66Service() |
||||
} |
||||
return Nip66Service.instance |
||||
} |
||||
|
||||
private isDiscoveryStale(cachedAt: number): boolean { |
||||
return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS |
||||
} |
||||
|
||||
/** |
||||
* Ingest kind 30166 events (e.g. from a query). Merges supported NIPs from multiple |
||||
* events for the same relay; keeps the most recent event's metadata, union of NIPs. |
||||
* Updates the IndexedDB cache of public lively relays and per-relay discovery cache. |
||||
*/ |
||||
loadFromEvents(events: NEvent[]): void { |
||||
const updatedKeys = new Set<string>() |
||||
for (const ev of events) { |
||||
const discovery = parseEvent(ev) |
||||
if (!discovery) continue |
||||
const key = normalizeUrl(discovery.url) || discovery.url |
||||
const existing = this.discoveryByUrl.get(key) |
||||
if (!existing) { |
||||
this.discoveryByUrl.set(key, discovery) |
||||
updatedKeys.add(key) |
||||
continue |
||||
} |
||||
const mergedNips = [...new Set([...existing.supportedNips, ...discovery.supportedNips])] |
||||
if (discovery.created_at >= existing.created_at) { |
||||
this.discoveryByUrl.set(key, { ...discovery, supportedNips: mergedNips }) |
||||
} else { |
||||
this.discoveryByUrl.set(key, { ...existing, supportedNips: mergedNips }) |
||||
} |
||||
updatedKeys.add(key) |
||||
} |
||||
const publicLively = this.buildPublicLivelyFromDiscovery() |
||||
if (publicLively.length > 0 && typeof window !== 'undefined') { |
||||
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {}) |
||||
} |
||||
if (typeof window !== 'undefined') { |
||||
for (const key of updatedKeys) { |
||||
const d = this.discoveryByUrl.get(key) |
||||
if (d) indexDb.setNip66Discovery(key, d).catch(() => {}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get discovery for a relay from memory or IndexedDB cache (if not stale). |
||||
* Use this to show UI immediately; then refetch if stale to update cache and GUI. |
||||
*/ |
||||
async getDiscoveryCached(relayUrl: string): Promise<TNip66RelayDiscovery | undefined> { |
||||
const key = normalizeUrl(relayUrl) || relayUrl |
||||
const fromMemory = this.discoveryByUrl.get(key) |
||||
if (fromMemory) return fromMemory |
||||
if (typeof window === 'undefined') return undefined |
||||
try { |
||||
const cached = await indexDb.getNip66Discovery(key) |
||||
if (!cached?.discovery || this.isDiscoveryStale(cached.cachedAt)) return undefined |
||||
this.discoveryByUrl.set(key, cached.discovery) |
||||
return cached.discovery |
||||
} catch { |
||||
return undefined |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* True if we should refetch discovery (no cache or IDB cache is stale). |
||||
* Uses IDB only (not memory), so we refetch when cached data is past TTL. |
||||
*/ |
||||
async isDiscoveryStaleForRelay(relayUrl: string): Promise<boolean> { |
||||
const key = normalizeUrl(relayUrl) || relayUrl |
||||
try { |
||||
const cached = await indexDb.getNip66Discovery(key) |
||||
return !cached || this.isDiscoveryStale(cached.cachedAt) |
||||
} catch { |
||||
return true |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Build list of relay URLs that are public (no auth, no payment) and have been |
||||
* reported by NIP-66 monitors (lively). Used for "add 3 random relays" censorship resilience. |
||||
*/ |
||||
private buildPublicLivelyFromDiscovery(): string[] { |
||||
const out: string[] = [] |
||||
for (const d of this.discoveryByUrl.values()) { |
||||
const authRequired = d.requirements.auth === true |
||||
const paymentRequired = d.requirements.payment === true |
||||
if (!authRequired && !paymentRequired) out.push(d.url) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
/** |
||||
* Returns relay URLs to use for "add 3 random relays to publish". Prefers NIP-66 discovery |
||||
* (in-memory then IndexedDB cache), falls back to static PUBLIC_LIVELY_RELAY_URLS. |
||||
*/ |
||||
async getPublicLivelyRelayUrls(): Promise<string[]> { |
||||
const fromMemory = this.buildPublicLivelyFromDiscovery() |
||||
if (fromMemory.length > 0) return fromMemory |
||||
if (typeof window === 'undefined') return [...PUBLIC_LIVELY_RELAY_URLS] |
||||
try { |
||||
const cached = await indexDb.getPublicLivelyRelayUrlsCache() |
||||
if (cached?.urls?.length && (Date.now() - cached.cachedAt) < PUBLIC_LIVELY_CACHE_TTL_MS) { |
||||
return cached.urls |
||||
} |
||||
} catch { |
||||
// ignore
|
||||
} |
||||
return [...PUBLIC_LIVELY_RELAY_URLS] |
||||
} |
||||
|
||||
getDiscovery(url: string): TNip66RelayDiscovery | undefined { |
||||
const key = normalizeUrl(url) || url |
||||
return this.discoveryByUrl.get(key) |
||||
} |
||||
|
||||
/** |
||||
* Ingest relay info from our own monitor (after we publish 30166). Adds the relay to |
||||
* in-memory discovery and updates the IndexedDB public lively cache so it can be used |
||||
* for "add 3 random relays" and relay info page liveliness display. |
||||
*/ |
||||
addDiscoveryFromRelayInfo(relayInfo: TRelayInfo): void { |
||||
const lim = relayInfo.limitation |
||||
const discovery: TNip66RelayDiscovery = { |
||||
url: relayInfo.url, |
||||
supportedNips: relayInfo.supported_nips ?? [], |
||||
requirements: { |
||||
auth: lim?.auth_required ?? false, |
||||
payment: lim?.payment_required ?? false |
||||
}, |
||||
created_at: Math.floor(Date.now() / 1000) |
||||
} |
||||
const key = normalizeUrl(relayInfo.url) || relayInfo.url |
||||
this.discoveryByUrl.set(key, discovery) |
||||
const publicLively = this.buildPublicLivelyFromDiscovery() |
||||
if (publicLively.length > 0 && typeof window !== 'undefined') { |
||||
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {}) |
||||
} |
||||
} |
||||
|
||||
/** Relay URLs that NIP-66 reports as supporting NIP-50 (search). Do not rely solely on this. */ |
||||
getSearchableRelayUrls(): string[] { |
||||
const out: string[] = [] |
||||
for (const d of this.discoveryByUrl.values()) { |
||||
if (d.supportedNips.includes(50)) out.push(d.url) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
/** True if we have a 30166 for this relay that lists NIP 50. Fall back to static list / NIP-11 when false. */ |
||||
isRelaySearchable(url: string): boolean { |
||||
const d = this.getDiscovery(url) |
||||
return d?.supportedNips.includes(50) ?? false |
||||
} |
||||
} |
||||
|
||||
export const nip66Service = Nip66Service.getInstance() |
||||
export default nip66Service |
||||
Loading…
Reference in new issue