Wire discovery and probing for LNURL mints, add rate-limited POST /api/index for user submissions, and optionally announce confirmed state to relays. Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
/**
|
||
* 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<string, unknown>;
|
||
|
||
/* ---------- 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:<port>`, 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<void>;
|
||
}
|
||
|
||
/**
|
||
* 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<TestRelay> {
|
||
const stored = new Map<string, StoredEvent>();
|
||
const sockets = new Set<Duplex>();
|
||
|
||
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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||
},
|
||
};
|
||
}
|