#!/usr/bin/env node // Hermes two-user integration test: Alice and Bob each derive a key from a // mnemonic, publish a phone claim to nos.lol, discover each other by phone, // and round-trip a NIP-17 gift-wrapped DM. Runs against the real production // relay — pass --relay wss://... to point elsewhere. // // Usage: node hermes-two-users-test.mjs import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; import WebSocket from "ws"; const HERMES = pathToFileURL(path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../lib/hermes.js" )).href; const H = await import(HERMES); const RELAY = process.argv.includes("--relay") ? process.argv[process.argv.indexOf("--relay") + 1] : "wss://nos.lol"; const log = (tag, ...msg) => console.log(`[${tag.padEnd(8)}]`, ...msg); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---- user: mnemonic → nostr key + handle -------------------------------- async function makeUser(name, mnemonic, phone) { const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic); const phoneHash = await H.hashPhone(phone); log(name, `pk=${pkHex.slice(0, 16)}… phone=${H.normalisePhone(phone)} hash=${phoneHash}`); return { name, sk, pkHex, npub, phone, phoneHash }; } // ---- relay helpers ------------------------------------------------------ function openRelay(label) { const ws = new WebSocket(RELAY); const ready = new Promise((resolve, reject) => { ws.once("open", () => { log(label, `relay connected: ${RELAY}`); resolve(); }); ws.once("error", reject); }); return { ws, ready }; } function publish(ws, ev) { return new Promise((resolve) => { const subId = ev.id; const onMsg = (buf) => { let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } if (msg[0] === "OK" && msg[1] === subId) { ws.off("message", onMsg); resolve({ ok: !!msg[2], reason: msg[3] || "" }); } }; ws.on("message", onMsg); ws.send(JSON.stringify(["EVENT", ev])); setTimeout(() => { ws.off("message", onMsg); resolve({ ok: false, reason: "timeout" }); }, 5000); }); } function query(ws, filter, { timeout = 4000 } = {}) { return new Promise((resolve) => { const subId = "sub-" + Math.random().toString(36).slice(2, 10); const events = []; const onMsg = (buf) => { let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } if (msg[0] === "EVENT" && msg[1] === subId) events.push(msg[2]); else if (msg[0] === "EOSE" && msg[1] === subId) finish(); }; const finish = () => { ws.off("message", onMsg); try { ws.send(JSON.stringify(["CLOSE", subId])); } catch {} resolve(events); }; ws.on("message", onMsg); ws.send(JSON.stringify(["REQ", subId, filter])); setTimeout(finish, timeout); }); } // ---- the test ----------------------------------------------------------- const ALICE_MNEMONIC = "abandon ability able about above absent absorb abstract absurd abuse access accident"; const BOB_MNEMONIC = "zoo yellow year xylophone wrong wrist worth world worry worm wool wood"; const alice = await makeUser("alice", ALICE_MNEMONIC, "+15550001000"); const bob = await makeUser("bob", BOB_MNEMONIC, "+15550002000"); const relay = openRelay("relay"); await relay.ready; // --- step 1: both publish phone claims log("step 1", "publishing phone claims…"); const aliceClaim = await H.buildPhoneClaimEvent({ sk: alice.sk, phone: alice.phone }); const bobClaim = await H.buildPhoneClaimEvent({ sk: bob.sk, phone: bob.phone }); const [rA, rB] = await Promise.all([publish(relay.ws, aliceClaim), publish(relay.ws, bobClaim)]); log("alice", `phone claim: ok=${rA.ok} ${rA.reason}`); log("bob", `phone claim: ok=${rB.ok} ${rB.reason}`); await sleep(300); // --- step 2: Alice looks up Bob by his phone log("step 2", "Alice looking up Bob by phone…"); const dtag = H.PHONE_DTAG_PREFIX + bob.phoneHash; const matches = await query(relay.ws, { kinds: [30078], "#d": [dtag], limit: 5 }); log("step 2", `found ${matches.length} event(s) for dtag ${dtag}`); if (!matches.length) { log("FAIL", "no phone-claim event came back"); process.exit(1); } const foundPk = matches[0].pubkey; log("step 2", `recovered pubkey: ${foundPk.slice(0, 16)}…`); if (foundPk !== bob.pkHex) { log("FAIL", `wrong pubkey — expected ${bob.pkHex.slice(0, 16)}…, got ${foundPk.slice(0, 16)}…`); process.exit(1); } log("PASS ", "✓ Alice discovered Bob from phone number"); // --- step 3: Bob also looks up Alice const dtagA = H.PHONE_DTAG_PREFIX + alice.phoneHash; const matchesA = await query(relay.ws, { kinds: [30078], "#d": [dtagA], limit: 5 }); if (!matchesA.length || matchesA[0].pubkey !== alice.pkHex) { log("FAIL", "Bob couldn't discover Alice"); process.exit(1); } log("PASS ", "✓ Bob discovered Alice from phone number"); // --- step 4: Alice sends a DM to Bob log("step 4", "Alice sending gift-wrapped DM to Bob…"); const greeting = "hey bob, this is alice. If you can read this, nip-17 round-tripped."; const wrap = H.wrapChat({ senderSk: alice.sk, recipientPkHex: bob.pkHex, text: greeting }); const rSend = await publish(relay.ws, wrap); log("alice", `wrap publish: ok=${rSend.ok} ${rSend.reason}`); // --- step 5: Bob subscribes for incoming wraps, decrypts, verifies sender log("step 5", "Bob listening for his wraps…"); const bobWraps = await query(relay.ws, { kinds: [1059], "#p": [bob.pkHex], limit: 10 }, { timeout: 6000 }); log("bob", `received ${bobWraps.length} gift-wrap(s)`); let foundOurs = null; for (const w of bobWraps) { try { const dec = H.unwrapChat({ receiverSk: bob.sk, wrap: w }); if (dec.text === greeting) { foundOurs = dec; break; } } catch {} } if (!foundOurs) { log("FAIL", "Bob did not receive our greeting"); process.exit(1); } if (foundOurs.senderPkHex !== alice.pkHex) { log("FAIL", `sender mismatch — expected ${alice.pkHex.slice(0, 16)}…, got ${foundOurs.senderPkHex.slice(0, 16)}…`); process.exit(1); } log("PASS ", "✓ Bob decrypted Alice's message and recovered her pubkey"); // --- step 6: Bob replies, Alice reads log("step 6", "Bob replying…"); const reply = "roger that, alice. reply working."; const rWrap = H.wrapChat({ senderSk: bob.sk, recipientPkHex: alice.pkHex, text: reply }); await publish(relay.ws, rWrap); const aliceWraps = await query(relay.ws, { kinds: [1059], "#p": [alice.pkHex], limit: 10 }, { timeout: 6000 }); let foundReply = null; for (const w of aliceWraps) { try { const dec = H.unwrapChat({ receiverSk: alice.sk, wrap: w }); if (dec.text === reply) { foundReply = dec; break; } } catch {} } if (!foundReply) { log("FAIL", "Alice did not get Bob's reply"); process.exit(1); } if (foundReply.senderPkHex !== bob.pkHex) { log("FAIL", "reply sender mismatch"); process.exit(1); } log("PASS ", "✓ Alice decrypted Bob's reply"); // --- step 7: Alice imports a vCard listing Bob's phone — exercise the // panel's parse + lookup path end-to-end without the UI. log("step 7", "Alice imports a vCard listing Bob…"); const vcard = `BEGIN:VCARD VERSION:3.0 FN:Bob Smith TEL;TYPE=CELL:+1 (555) 000-2000 END:VCARD BEGIN:VCARD VERSION:3.0 FN:Someone Else TEL:+15559999999 END:VCARD`; const entries = []; for (const block of vcard.split(/BEGIN:VCARD/i).slice(1)) { const body = block.split(/END:VCARD/i)[0] || ""; const fn = (body.match(/^FN(?:;[^:]*)?:(.+)$/mi) || [])[1]; const tel = (body.match(/^TEL(?:;[^:]*)?:(.+)$/mi) || [])[1]; if (tel) entries.push({ name: (fn || "").trim(), phone: tel.trim() }); } log("step 7", `parsed ${entries.length} vCard entries`); // Mirror findByPhones from main.js, run against the live relay. const phoneMap = new Map(); for (const e of entries) { try { phoneMap.set(await H.hashPhone(e.phone), e.phone); } catch {} } const vcDtags = Array.from(phoneMap.keys()).map((h) => H.PHONE_DTAG_PREFIX + h); const vcMatches = await query(relay.ws, { kinds: [30078], "#d": vcDtags, limit: vcDtags.length * 2 }, { timeout: 4000 }); const bestByDtag = new Map(); for (const r of vcMatches) { const d = (r.tags.find((t) => t[0] === "d") || [])[1]; const prev = bestByDtag.get(d); if (!prev || r.created_at > prev.created_at) bestByDtag.set(d, r); } const resolved = []; for (const [dtag, hit] of bestByDtag) { const hash = dtag.slice(H.PHONE_DTAG_PREFIX.length); resolved.push({ phone: phoneMap.get(hash), pkHex: hit.pubkey }); } log("step 7", `${resolved.length} vCard entries matched existing Hermes users`); const bobMatch = resolved.find(r => r.pkHex === bob.pkHex); if (!bobMatch) { log("FAIL", "vCard parse → findByPhones did not surface Bob"); process.exit(1); } log("PASS ", `✓ Alice's vCard import auto-discovered Bob (${bobMatch.phone} → ${bobMatch.pkHex.slice(0,16)}…)`); const stranger = resolved.find(r => r.phone.includes("9999999")); if (stranger) { log("FAIL", "stranger phone unexpectedly matched"); process.exit(1); } log("PASS ", "✓ unknown phone number produced no match (as expected)"); log("DONE ", "all phone-discovery + DM + vCard-import round-trips passed ✓"); relay.ws.close(); process.exit(0);