diff --git a/src/main.c b/src/main.c index a4b6c25..a044fd4 100644 --- a/src/main.c +++ b/src/main.c @@ -1260,6 +1260,13 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru continue; } + // NIP-01: limit:0 means return zero stored events — skip SQL entirely, just send EOSE + cJSON* limit_check = cJSON_GetObjectItemCaseSensitive(filter, "limit"); + if (limit_check && cJSON_IsNumber(limit_check) && (int)cJSON_GetNumberValue(limit_check) == 0) { + DEBUG_LOG("Filter has limit:0 — skipping query, sending EOSE immediately (NIP-01 compliance)"); + continue; + } + // Reset bind params for this filter free_bind_params(bind_params, bind_param_count); bind_params = NULL; diff --git a/src/main.h b/src/main.h index 75dafee..a041bb1 100644 --- a/src/main.h +++ b/src/main.h @@ -13,8 +13,8 @@ // Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define CRELAY_VERSION_MAJOR 1 #define CRELAY_VERSION_MINOR 2 -#define CRELAY_VERSION_PATCH 50 -#define CRELAY_VERSION "v1.2.50" +#define CRELAY_VERSION_PATCH 51 +#define CRELAY_VERSION "v1.2.51" // Relay metadata (authoritative source for NIP-11 information) #define RELAY_NAME "C-Relay" diff --git a/tests/kind4_disconnect_probe.mjs b/tests/kind4_disconnect_probe.mjs new file mode 100644 index 0000000..46ca9fc --- /dev/null +++ b/tests/kind4_disconnect_probe.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +import WebSocket from 'ws'; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip19, + nip42, +} from 'nostr-tools'; + +const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:7777'; +const EVENT_COUNT = Number(process.env.EVENT_COUNT || 5); +const DELAY_MS = Number(process.env.DELAY_MS || 2000); +const RECIPIENT_PUBKEY_ENV = process.env.RECIPIENT_PUBKEY || ''; +const SECRET_INPUT = process.env.NOSTR_SECRET_KEY || ''; +const OVERALL_TIMEOUT_MS = Number(process.env.OVERALL_TIMEOUT_MS || 120000); + +function now() { + return new Date().toISOString(); +} + +function log(msg, extra = null) { + if (extra !== null) { + console.log(`[${now()}] ${msg}`, extra); + } else { + console.log(`[${now()}] ${msg}`); + } +} + +function normalizeHexSecret(secretInput) { + if (!secretInput) return null; + + const raw = secretInput.trim(); + if (/^[0-9a-fA-F]{64}$/.test(raw)) { + return raw.toLowerCase(); + } + + if (raw.startsWith('nsec1')) { + const decoded = nip19.decode(raw); + if (decoded.type !== 'nsec') { + throw new Error('NOSTR_SECRET_KEY is nsec-like but did not decode as nsec'); + } + if (typeof decoded.data === 'string') { + return decoded.data.toLowerCase(); + } + if (decoded.data instanceof Uint8Array) { + return Buffer.from(decoded.data).toString('hex').toLowerCase(); + } + throw new Error('Unsupported nsec decoded payload type'); + } + + throw new Error('NOSTR_SECRET_KEY must be 64-char hex or nsec1...'); +} + +const hexSecret = normalizeHexSecret(SECRET_INPUT) || Buffer.from(generateSecretKey()).toString('hex'); +const secretKey = Buffer.from(hexSecret, 'hex'); +const senderPubkey = getPublicKey(secretKey); +const recipientPubkey = RECIPIENT_PUBKEY_ENV || senderPubkey; + +let ws; +let currentChallenge = null; +let authenticated = false; +let authAttempts = 0; +let sentAccepted = 0; +let nextSeq = 1; +let pendingEvent = null; +let waitingForAuth = false; +let finished = false; +let overallTimer = null; + +function makeKind4Event(seq) { + const eventTemplate = { + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', recipientPubkey]], + content: `kind4 disconnect probe seq=${seq} ts=${Date.now()}`, + pubkey: senderPubkey, + }; + return finalizeEvent(eventTemplate, secretKey); +} + +function sendJson(arr) { + const payload = JSON.stringify(arr); + ws.send(payload); +} + +function sendAuth(challenge) { + const authTemplate = nip42.makeAuthEvent(RELAY_URL, challenge); + const signedAuth = finalizeEvent(authTemplate, secretKey); + authAttempts += 1; + log(`Sending AUTH attempt #${authAttempts} with challenge ${challenge}`); + sendJson(['AUTH', signedAuth]); +} + +function scheduleNext() { + if (nextSeq > EVENT_COUNT) { + log(`All ${EVENT_COUNT} kind-4 events were accepted without disconnect`); + cleanupAndExit(0); + return; + } + setTimeout(() => { + if (!finished) { + publishNext(); + } + }, DELAY_MS); +} + +function publishNext() { + if (waitingForAuth) { + log('Still waiting for auth completion, postponing publish'); + return; + } + + const ev = makeKind4Event(nextSeq); + pendingEvent = ev; + log(`Publishing kind-4 seq=${nextSeq} id=${ev.id.slice(0, 12)}...`); + sendJson(['EVENT', ev]); +} + +function retryPendingAfterAuth() { + if (!pendingEvent) return; + waitingForAuth = false; + log(`Retrying pending kind-4 id=${pendingEvent.id.slice(0, 12)}... after auth`); + sendJson(['EVENT', pendingEvent]); +} + +function handleAuthRequiredSignal(source, msg) { + waitingForAuth = true; + log(`Relay signaled auth-required via ${source}: ${msg}`); + if (currentChallenge) { + sendAuth(currentChallenge); + } else { + log('No AUTH challenge received yet; waiting for relay AUTH challenge message'); + } +} + +function cleanupAndExit(code) { + if (finished) return; + finished = true; + if (overallTimer) clearTimeout(overallTimer); + + const summary = { + relay: RELAY_URL, + sentAccepted, + expected: EVENT_COUNT, + authAttempts, + authenticated, + senderPubkey, + recipientPubkey, + }; + + if (code === 0) { + log('PASS: Connection remained stable through repeated kind-4 publish cycle', summary); + } else { + log('FAIL: Relay disconnected or test did not complete successfully', summary); + } + + try { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } catch (_) {} + + process.exit(code); +} + +function main() { + log('Starting kind-4 disconnect probe', { + relay: RELAY_URL, + eventCount: EVENT_COUNT, + delayMs: DELAY_MS, + senderPubkey, + recipientPubkey, + }); + + ws = new WebSocket(RELAY_URL); + + overallTimer = setTimeout(() => { + log(`Overall timeout reached (${OVERALL_TIMEOUT_MS}ms)`); + cleanupAndExit(1); + }, OVERALL_TIMEOUT_MS); + + ws.on('open', () => { + log('WebSocket connected'); + publishNext(); + }); + + ws.on('message', (raw) => { + const text = raw.toString(); + let msg; + try { + msg = JSON.parse(text); + } catch { + log(`Non-JSON relay message: ${text}`); + return; + } + + const t = msg?.[0]; + + if (t === 'AUTH') { + currentChallenge = msg?.[1] || null; + log(`Received AUTH challenge: ${currentChallenge}`); + if (currentChallenge) sendAuth(currentChallenge); + return; + } + + if (t === 'NOTICE') { + const notice = String(msg?.[1] || ''); + log(`NOTICE: ${notice}`); + + if (notice.toLowerCase().includes('auth-required')) { + handleAuthRequiredSignal('NOTICE', notice); + return; + } + + if (notice.toLowerCase().includes('authentication successful')) { + authenticated = true; + log('Relay confirmed NIP-42 authentication success'); + retryPendingAfterAuth(); + } + return; + } + + if (t === 'OK') { + const eventId = msg?.[1]; + const ok = !!msg?.[2]; + const reason = String(msg?.[3] || ''); + log(`OK for ${String(eventId).slice(0, 12)}... accepted=${ok} reason=${reason}`); + + if (!ok && reason.toLowerCase().includes('auth-required')) { + handleAuthRequiredSignal('OK', reason); + return; + } + + if (pendingEvent && eventId === pendingEvent.id) { + if (ok) { + sentAccepted += 1; + nextSeq += 1; + pendingEvent = null; + waitingForAuth = false; + scheduleNext(); + } else { + log(`Pending event rejected: ${reason}`); + cleanupAndExit(1); + } + } + return; + } + + log('Relay message', msg); + }); + + ws.on('close', (code, reasonBuf) => { + const reason = reasonBuf?.toString?.() || ''; + log(`WebSocket closed code=${code} reason=${reason}`); + if (!finished) { + if (sentAccepted >= EVENT_COUNT) { + cleanupAndExit(0); + } else { + cleanupAndExit(1); + } + } + }); + + ws.on('error', (err) => { + log(`WebSocket error: ${err.message}`); + cleanupAndExit(1); + }); +} + +main();