Index, probe, and announce LNURL mints in the API.
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>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user