The /overview page's UI mockup used a fixed 220px/1fr grid that overflowed on 375px viewports. Added a @media (<=640px) rule that drops the contacts pane, expands the thread to the full width, breaks out to the screen edges, and tightens the window-bar + compose padding so the mockup reads cleanly on a phone. Added tests/hermes-two-users.test.mjs — a Node integration suite that drives two personas (alice, bob) through the full flow against the live nos.lol relay: BIP-39 key derivation, phone-claim publish, phone→pubkey discovery both directions, bidirectional NIP-17 gift-wrap DM delivery, vCard parse + findByPhones. Eight assertions, all pass. Run with: node TheseusNavigator/tests/hermes-two-users.test.mjs
218 lines
9 KiB
JavaScript
218 lines
9 KiB
JavaScript
#!/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);
|