/** * A Nostr relay, in one file, for tests. * * Enough of NIP-01 to accept an EVENT, answer a REQ and close a subscription, plus the * addressable-replacement rule, which is not optional here: the whole point of the * round-trip test is a `kind:38174`, and an addressable kind that a relay stores twice * would let a broken publisher pass. * * Written rather than depended on, deliberately. The alternatives were a real relay * binary (which CI would have to install and keep running) or a `ws` dependency added to * the lockfile for test-only code. Node 22 ships a WebSocket *client* — which is what * nostr-tools uses — but no server, so the handshake and framing below are the actual * cost of not adding either, and RFC 6455 is small when the only frames that matter are * short unfragmented text ones. * * **Not for production.** No authentication, no persistence, no NIP-42, no rate limits, * no fragmentation support beyond a single continuation, and everything lives in a Map * until the process exits. `api/src/check-lnurl.ts` is the only caller. */ import { createHash } from 'node:crypto'; import { createServer, type IncomingMessage, type Server } from 'node:http'; import type { Duplex } from 'node:stream'; /** RFC 6455's fixed handshake GUID. */ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; interface StoredEvent { id: string; pubkey: string; kind: number; created_at: number; content: string; tags: string[][]; sig?: string; } type Filter = Record; /* ---------- framing ---------- */ /** Encode one unfragmented text frame, server to client, never masked. */ function encodeText(text: string): Buffer { const payload = Buffer.from(text, 'utf8'); const length = payload.length; let header: Buffer; if (length < 126) { header = Buffer.from([0x81, length]); } else if (length < 65536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(length, 2); } else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(length), 2); } return Buffer.concat([header, payload]); } interface DecodedFrame { opcode: number; payload: Buffer; /** Total bytes consumed, so the caller can advance its buffer. */ size: number; } /** * Decode one frame from the front of `buffer`, or null when it is not all there yet. * * Client frames are always masked, per the spec, so the mask is applied unconditionally * when the bit is set and ignored when it is not — a browser or Node client never omits * it, and a test relay has no reason to reject one that did. */ function decodeFrame(buffer: Buffer): DecodedFrame | null { if (buffer.length < 2) return null; const first = buffer[0]!; const second = buffer[1]!; const opcode = first & 0x0f; const masked = (second & 0x80) !== 0; let length = second & 0x7f; let offset = 2; if (length === 126) { if (buffer.length < offset + 2) return null; length = buffer.readUInt16BE(offset); offset += 2; } else if (length === 127) { if (buffer.length < offset + 8) return null; const big = buffer.readBigUInt64BE(offset); // A test relay has no business buffering a 4GB frame. if (big > 8n * 1024n * 1024n) throw new Error('frame too large'); length = Number(big); offset += 8; } let mask: Buffer | null = null; if (masked) { if (buffer.length < offset + 4) return null; mask = buffer.subarray(offset, offset + 4); offset += 4; } if (buffer.length < offset + length) return null; const payload = Buffer.from(buffer.subarray(offset, offset + length)); if (mask) { for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ mask[i % 4]!; } return { opcode, payload, size: offset + length }; } /* ---------- filters ---------- */ function matchesFilter(event: StoredEvent, filter: Filter): boolean { const ids = filter['ids'] as string[] | undefined; if (ids && !ids.includes(event.id)) return false; const authors = filter['authors'] as string[] | undefined; if (authors && !authors.includes(event.pubkey)) return false; const kinds = filter['kinds'] as number[] | undefined; if (kinds && !kinds.includes(event.kind)) return false; const since = filter['since'] as number | undefined; if (typeof since === 'number' && event.created_at < since) return false; const until = filter['until'] as number | undefined; if (typeof until === 'number' && event.created_at > until) return false; // `#e`, `#p`, `#d`, `#k`, `#u`: match any value of that single-letter tag. for (const [key, wanted] of Object.entries(filter)) { if (!key.startsWith('#') || key.length !== 2) continue; const name = key.slice(1); const values = wanted as string[]; const present = event.tags.filter((t) => t[0] === name).map((t) => t[1]); if (!present.some((value) => value !== undefined && values.includes(value))) return false; } return true; } /** * The storage key for an event. * * Addressable kinds (30000–39999) are keyed by `kind:pubkey:d`, so a second announcement * from the same publisher for the same mint replaces the first instead of accumulating — * which is exactly the behaviour `kind:38174` depends on and therefore exactly what a * test of it must reproduce. Everything else is keyed by its own id. */ function storageKey(event: StoredEvent): string { if (event.kind >= 30000 && event.kind < 40000) { const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? ''; return `${event.kind}:${event.pubkey}:${d}`; } if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) { return `${event.kind}:${event.pubkey}`; } return event.id; } /* ---------- the relay ---------- */ export interface TestRelay { /** `ws://127.0.0.1:`, ready to hand to a pool. */ url: string; /** Every event currently stored, newest first. */ events(): StoredEvent[]; /** Events of one kind, newest first. */ byKind(kind: number): StoredEvent[]; close(): Promise; } /** * Start a relay on an ephemeral port. * * Port 0 rather than a fixed one so two tests, or two CI jobs on one machine, cannot * collide — the caller reads the real port back off `url`. */ export async function startTestRelay(): Promise { const stored = new Map(); const sockets = new Set(); const server: Server = createServer((_req, res) => { // Not a websocket upgrade. NIP-11 would go here on a real relay. res.writeHead(426, { 'Content-Type': 'text/plain' }); res.end('websocket only'); }); server.on('upgrade', (req: IncomingMessage, socket: Duplex) => { const key = req.headers['sec-websocket-key']; if (typeof key !== 'string') { socket.destroy(); return; } const accept = createHash('sha1').update(key + WS_GUID).digest('base64'); socket.write( 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, ); sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.on('error', () => sockets.delete(socket)); const send = (message: unknown): void => { if (!socket.destroyed) socket.write(encodeText(JSON.stringify(message))); }; let buffer = Buffer.alloc(0); socket.on('data', (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); for (;;) { let frame: DecodedFrame | null; try { frame = decodeFrame(buffer); } catch { socket.destroy(); return; } if (!frame) break; buffer = buffer.subarray(frame.size); if (frame.opcode === 0x8) { socket.end(); return; } // Ping: answer with a pong carrying the same payload, per the spec. if (frame.opcode === 0x9) { const pong = encodeText(''); pong[0] = 0x8a; socket.write(pong); continue; } if (frame.opcode !== 0x1) continue; let message: unknown; try { message = JSON.parse(frame.payload.toString('utf8')); } catch { continue; } if (!Array.isArray(message)) continue; const [verb, ...rest] = message as [string, ...unknown[]]; if (verb === 'EVENT') { const event = rest[0] as StoredEvent | undefined; if (!event?.id) continue; const existing = stored.get(storageKey(event)); // Older replacement for an addressable kind: keep what is there, and still // answer OK, which is what a real relay does. if (!existing || existing.created_at <= event.created_at) { stored.set(storageKey(event), event); } send(['OK', event.id, true, '']); continue; } if (verb === 'REQ') { const subId = rest[0] as string; const filters = rest.slice(1) as Filter[]; const all = [...stored.values()].sort((a, b) => b.created_at - a.created_at); for (const filter of filters) { const limit = typeof filter['limit'] === 'number' ? (filter['limit'] as number) : Infinity; let sent = 0; for (const event of all) { if (sent >= limit) break; if (!matchesFilter(event, filter)) continue; send(['EVENT', subId, event]); sent++; } } send(['EOSE', subId]); continue; } if (verb === 'CLOSE') { send(['CLOSED', rest[0] as string, '']); } } }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('relay did not bind a port'); return { url: `ws://127.0.0.1:${address.port}`, events: () => [...stored.values()].sort((a, b) => b.created_at - a.created_at), byKind: (kind) => [...stored.values()].filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at), close: async () => { for (const socket of sockets) socket.destroy(); sockets.clear(); await new Promise((resolve) => server.close(() => resolve())); }, }; }