clone

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

feat/standalone-relay
tinyrelay / scripts / qa / nip46-bunker.mjs
nip46-bunker.mjs 3.8K
1#!/usr/bin/env node2// A throwaway NIP-46 bunker for trying the Bunker URL sign-in flow locally.3// It runs a minimal in-memory relay on 127.0.0.1:8789, answers connect,4// ping, get_public_key and sign_event requests for a fresh user key, and5// prints the bunker URL to paste into the sign-in page. Set BUNKER_USER_SECRET6// to a hex secret to sign as a specific key.7import { WebSocketServer, WebSocket } from "ws";8import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";9import * as nip44 from "nostr-tools/nip44";10import { hexToBytes } from "@noble/hashes/utils.js";1112globalThis.WebSocket = WebSocket;1314const port = Number(process.env.BUNKER_PORT ?? 8789);15const bunkerSecret = generateSecretKey();16const bunkerPubkey = getPublicKey(bunkerSecret);17const userSecret = process.env.BUNKER_USER_SECRET ? hexToBytes(process.env.BUNKER_USER_SECRET) : generateSecretKey();18const userPubkey = getPublicKey(userSecret);19const clients = new Map();20const events = [];2122const matches = (event, filter) => {23 if (filter.kinds && !filter.kinds.includes(event.kind)) return false;24 if (filter.authors && !filter.authors.includes(event.pubkey)) return false;25 for (const [key, values] of Object.entries(filter)) {26 if (key[0] === "#" && !event.tags.some(tag => tag[0] === key.slice(1) && values.includes(tag[1]))) return false;27 }28 return true;29};3031const broadcast = event => {32 events.push(event);33 for (const [peer, subscriptions] of clients) {34 for (const [id, filters] of subscriptions) {35 if (filters.some(filter => matches(event, filter))) peer.send(JSON.stringify(["EVENT", id, event]));36 }37 }38};3940const server = new WebSocketServer({ host: "127.0.0.1", port });41server.on("connection", socket => {42 const subscriptions = new Map();43 clients.set(socket, subscriptions);44 socket.on("message", raw => {45 let message;46 try { message = JSON.parse(raw); } catch { return; }47 if (message[0] === "REQ") {48 const [id, ...filters] = message.slice(1);49 subscriptions.set(id, filters);50 for (const event of events) {51 if (filters.some(filter => matches(event, filter))) socket.send(JSON.stringify(["EVENT", id, event]));52 }53 socket.send(JSON.stringify(["EOSE", id]));54 } else if (message[0] === "EVENT") {55 broadcast(message[1]);56 socket.send(JSON.stringify(["OK", message[1].id, true, ""]));57 } else if (message[0] === "CLOSE") {58 subscriptions.delete(message[1]);59 }60 });61 socket.on("close", () => clients.delete(socket));62});6364const reply = (clientPubkey, payload) => {65 const conversation = nip44.getConversationKey(bunkerSecret, clientPubkey);66 broadcast(finalizeEvent({67 kind: 24133,68 tags: [["p", clientPubkey]],69 content: nip44.encrypt(JSON.stringify(payload), conversation),70 created_at: Math.floor(Date.now() / 1000)71 }, bunkerSecret));72};7374const answer = request => {75 switch (request.method) {76 case "connect": return "ack";77 case "ping": return "pong";78 case "get_public_key": return userPubkey;79 case "sign_event": return JSON.stringify(finalizeEvent(JSON.parse(request.params[0]), userSecret));80 default: throw Error("unsupported method " + request.method);81 }82};8384const handled = new WeakSet();85setInterval(() => {86 for (const event of events) {87 if (event.kind !== 24133 || event.pubkey === bunkerPubkey || handled.has(event)) continue;88 handled.add(event);89 try {90 const conversation = nip44.getConversationKey(bunkerSecret, event.pubkey);91 const request = JSON.parse(nip44.decrypt(event.content, conversation));92 reply(event.pubkey, { id: request.id, result: answer(request) });93 } catch (err) {94 console.error("bunker request failed:", err.message);95 }96 }97}, 50);9899console.log("user pubkey: " + userPubkey);100console.log(`bunker://${bunkerPubkey}?relay=${encodeURIComponent("ws://127.0.0.1:" + port)}`);