clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / scripts / qa / signer-resume-fixture.mjs
signer-resume-fixture.mjs 6.8K
1#!/usr/bin/env node2// Local-only NIP-46 signer fixture for browser suspension and recovery checks.3// It exposes a tiny Nostr relay and a deterministic remote signer. The control4// endpoint deliberately drops transport connections so a browser can exercise5// its lost-signer and reconnect states without a phone or external relay.6import { createServer } from "node:http";7import { WebSocketServer, WebSocket } from "ws";8import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";9import { BunkerSigner } from "nostr-tools/nip46";10import * as nip44 from "nostr-tools/nip44";1112const relayPort = Number(process.env.SIGNER_RELAY_PORT ?? 18471);13const controlPort = Number(process.env.SIGNER_CONTROL_PORT ?? 18472);14if (!Number.isInteger(relayPort) || !Number.isInteger(controlPort)) throw Error("ports must be integers");1516const key = n => Uint8Array.from(Buffer.from(String(n).padStart(64, "0"), "hex"));17// These are the public QA identities used by scripts/qa/buzz-chat-fixture.mjs.18const phoneSecret = key(process.env.SIGNER_PHONE_SECRET ?? "1");19const serviceSecret = key(process.env.SIGNER_SERVICE_SECRET ?? "4");20const clientSecret = key(process.env.SIGNER_CLIENT_SECRET ?? "5");21const phonePubkey = getPublicKey(phoneSecret);22const servicePubkey = getPublicKey(serviceSecret);23const clientPubkey = getPublicKey(clientSecret);24const relayURL = `ws://127.0.0.1:${relayPort}`;25const events = [];26const peers = new Map();27let offline = false;2829const matches = (event, filter) => {30 if (filter.ids && !filter.ids.includes(event.id)) return false;31 if (filter.kinds && !filter.kinds.includes(event.kind)) return false;32 if (filter.authors && !filter.authors.includes(event.pubkey)) return false;33 if (filter.since && event.created_at < filter.since) return false;34 if (filter.until && event.created_at > filter.until) return false;35 for (const [name, values] of Object.entries(filter)) {36 if (name.startsWith("#") && !event.tags.some(tag => tag[0] === name.slice(1) && values.includes(tag[1]))) return false;37 }38 return true;39};40const send = (socket, value) => {41 if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(value));42};43const broadcast = event => {44 events.push(event);45 for (const [socket, subscriptions] of peers) {46 for (const [id, filters] of subscriptions) {47 if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]);48 }49 }50};5152const relay = new WebSocketServer({ host: "127.0.0.1", port: relayPort });53relay.on("connection", socket => {54 if (offline) {55 socket.close(1013, "fixture offline");56 return;57 }58 const subscriptions = new Map();59 peers.set(socket, subscriptions);60 socket.on("message", raw => {61 if (offline) return;62 let message;63 try { message = JSON.parse(raw); } catch { return; }64 if (message[0] === "REQ") {65 const [id, ...filters] = message.slice(1);66 subscriptions.set(id, filters);67 for (const event of events) if (filters.some(filter => matches(event, filter))) send(socket, ["EVENT", id, event]);68 send(socket, ["EOSE", id]);69 } else if (message[0] === "EVENT" && message[1]) {70 const event = message[1];71 if (verifyEvent(event)) {72 broadcast(event);73 send(socket, ["OK", event.id, true, ""]);74 } else send(socket, ["OK", event.id, false, "invalid event"]);75 } else if (message[0] === "CLOSE") subscriptions.delete(message[1]);76 });77 socket.on("close", () => peers.delete(socket));78});7980const reply = (requestEvent, request) => {81 const conversation = nip44.getConversationKey(serviceSecret, requestEvent.pubkey);82 const result = (() => {83 switch (request.method) {84 case "connect": return "ack";85 case "ping": return "pong";86 case "get_public_key": return phonePubkey;87 case "sign_event": return JSON.stringify(finalizeEvent(JSON.parse(request.params[0]), phoneSecret));88 case "nip44_encrypt": return nip44.encrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0]));89 case "nip44_decrypt": return nip44.decrypt(request.params[1], nip44.getConversationKey(phoneSecret, request.params[0]));90 default: throw Error("unsupported method " + request.method);91 }92 })();93 broadcast(finalizeEvent({94 kind: 24133,95 tags: [["p", requestEvent.pubkey]],96 content: nip44.encrypt(JSON.stringify({ id: request.id, result }), conversation),97 created_at: Math.floor(Date.now() / 1000)98 }, serviceSecret));99};100101const handled = new Set();102const requestPoller = setInterval(() => {103 if (offline) return;104 for (const event of events) {105 if (event.kind !== 24133 || event.pubkey !== clientPubkey || !event.tags.some(tag => tag[0] === "p" && tag[1] === servicePubkey) || handled.has(event)) continue;106 handled.add(event);107 try {108 const conversation = nip44.getConversationKey(serviceSecret, event.pubkey);109 reply(event, JSON.parse(nip44.decrypt(event.content, conversation)));110 } catch (error) { console.error("signer request failed:", error.message); }111 }112}, 25);113114const control = createServer((request, response) => {115 if (request.method !== "POST" || !["/offline", "/online"].includes(request.url)) {116 response.writeHead(404, { "content-type": "text/plain" });117 response.end("use POST /offline or POST /online\n");118 return;119 }120 offline = request.url === "/offline";121 if (offline) for (const socket of peers.keys()) socket.close(1013, "fixture offline");122 response.writeHead(200, { "content-type": "application/json" });123 response.end(JSON.stringify({ offline }));124});125126const clientPointer = { pubkey: servicePubkey, relays: [relayURL] };127const runSelfTest = async () => {128 const signer = BunkerSigner.fromBunker(clientSecret, clientPointer);129 await signer.connect({ name: "tinyrelay signer resume fixture" });130 if ((await signer.getPublicKey()) !== phonePubkey) throw Error("unexpected signer identity");131 const signed = await signer.signEvent({ kind: 1, content: "local signer fixture", tags: [], created_at: 1 });132 if (!verifyEvent(signed) || signed.pubkey !== phonePubkey) throw Error("signer returned invalid event");133 await signer.close();134 signer.pool.destroy();135 console.log("self-test: connect, get_public_key and sign_event passed");136};137138control.listen(controlPort, "127.0.0.1", async () => {139 console.log(JSON.stringify({140 relay: relayURL,141 control: `http://127.0.0.1:${controlPort}`,142 bunker: `bunker://${servicePubkey}?relay=${encodeURIComponent(relayURL)}`,143 phonePubkey,144 servicePubkey,145 clientPubkey,146 localOnly: true,147 controls: ["POST /offline", "POST /online"]148 }, null, 2));149 try { await runSelfTest(); } catch (error) { console.error("self-test failed:", error); process.exitCode = 1; }150});151152const stop = () => {153 clearInterval(requestPoller);154 for (const socket of peers.keys()) socket.terminate();155 control.close();156 relay.close();157};158process.once("SIGINT", stop);159process.once("SIGTERM", stop);