tinyrelayfile
tinyrelay/scripts/qa/signer-resume-fixture.mjs/

scripts/qa/signer-resume-fixture.mjs raw
1#!/usr/bin/env node
2// 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 control
4// endpoint deliberately drops transport connections so a browser can exercise
5// 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";
11
12const 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");
15
16const 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;
28
29const 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};
51
52const 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});
79
80const 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};
100
101const 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);
113
114const 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});
125
126const 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};
137
138control.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});
151
152const 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);

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs