Compare commits

...

9 commits

Author SHA1 Message Date
Local Dev
b2fef43607 Theseus 0.3.103: sidebar panel context menu + Hermes icon framing
Right-click on a sidebar panel (WhatsApp, Telegram, LLMs, add-on panels
like Hermes) now opens a native menu with Copy image / Save image as /
Open link in new tab / Copy / Back / Forward / Reload. Previously the
sidebar views had no context-menu listener attached, so sites like
WhatsApp Web that intercept right-click left the user with no way to
copy an image.

Hermes mark in the sidebar strip now zooms to the V-fold and wings;
envelope body partially visible. The original viewBox had ~43% empty
space top-and-bottom, so the glyph rendered at ~15 px inside the 26 px
icon frame.
2026-10-22 13:48:09 +03:00
Local Dev
c944952f04 hermes panel: mirror the Sign in / Sign up tab bar from the web
Theseus's sidebar panel.html had the pre-redesign flow (two primary buttons
+ Create a new account toggle + Name+password/Seed-phrase chip switcher).
Replaced with the same persistent pill tab bar the web messenger uses:
Sign in / Sign up visible together, active one filled, inactive outlined;
tapping an inactive tab swaps the form below with no back step. "Recover
with seed phrase" is a quiet link that opens its own form (with ← Back).

The Theseus-only "Open with Theseus ID" quick path now lives inside the
Sign in tab's form and hides entirely when the vault is locked — no more
disabled button + hint about unlocking the vault cluttering the signin
screen. Bumped the bundled addon to 0.2.9.
2026-10-22 13:28:47 +03:00
Local Dev
4b3fd4be42 hermes: mobile mockup collapse + two-user integration test
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
2026-10-22 11:55:06 +03:00
Local Dev
bbe5d3d298 hermes: cloud-wrapped backup (opt-in B) + mobile site polish
Users can now sign in with name + password on a device that has never seen the
account by opting into a cloud backup at account creation. The seed phrase is
locally re-wrapped with scrypt + AES-GCM keyed by the password and published
as a NIP-78 kind:30078 event under d-tag `hermes.backup.v1/<sha256(name)>`.
Relays only ever see opaque ciphertext; the password is the only decryption
key, so backup strength is password strength.

Signin tries the local wrap first (fast, no network) and falls back to the
cloud copy if the device hasn't been used before. Settings → Backup now has
"Back up to cloud" / "Re-upload" / "Remove cloud backup" controls that reuse
the Reveal-seed password field as the key.

Site: added @media breakpoints to shared.css so the header collapses cleanly
on narrow screens (icon-only brand under 380px, hidden "Sign in" label under
640px), the hero mark drops to 84px, h1 scales down, and buttons shrink to
touch sizes. Also fixed a stale reference to the removed `mnemonicBox` id in
the Create-account toggle that was throwing a TypeError on mobile.
2026-10-22 03:46:45 +03:00
Local Dev
6b4654231b hermes settings: scrollable modal + Address book vCard import
Two Settings-pane fixes:

1. .modal was clipped on short viewports (mobile landscape, small
   laptop screens): place-items:center + fixed max-width with no
   max-height meant the content below Phone discovery / Backup fell
   off the bottom. Add max-height: calc(100vh - 24px) + overflow-y:
   auto so the whole thing scrolls inside the backdrop. This is why
   the user couldn't see Phone discovery on mobile.
2. Address book section between Phone discovery and Backup: 'Import
   contacts from vCard' triggers a hidden file picker (<input
   type=file accept=.vcf>) that reads the file client-side, extracts
   FN + TEL per VCARD block, hands the phone list to findByPhones,
   and auto-adds the matched Hermes identities as contacts with the
   vCard name as nickname.

Lives in both the Theseus addon panel and the hermes.x web messenger.
Phone discovery was already in Settings — the modal clipping just hid
it on smaller viewports.
2026-10-22 03:12:28 +03:00
Local Dev
5984d39253 hermes signin: one Sign in button + two chips, nav + mobile dark bg
Signin screen (both the Theseus addon panel and hermes.x/messenger):
- Open with Theseus ID and Create a new account stay as top-level
  actions (unchanged).
- Beneath them, a 'Sign in' divider + two chips [Name + password]
  [Seed phrase] drive one unified Sign in button. Toggle swaps the
  input area between the password form and the mnemonic textarea.
  The old 'Sign in with name + password' and 'Open an existing
  account' secondary buttons are gone.

Site nav restructure (site-hermes-x/):
- Header now: Hermes.X brand + Overview + Sign in icon (opens
  /messenger). Messenger tab, Branding tab, and Get Theseus pill are
  all removed from the header.
- Footer carries Branding + Open Messenger + Get Theseus links as
  secondary nav. Branding is 'here' on the brand kit page.

Mobile dark bg fix (shared.css):
- color-scheme: dark on :root so mobile browsers don't flip the
  page into a light theme that makes the sky-blue mark unreadable.
- html { background: var(--bg) } so the pre-body paint is dark too
  (Safari / Firefox mobile briefly show the html bg before body css
  loads).
2026-10-22 03:08:40 +03:00
Local Dev
975edba88f hermes signin: hide the hermes: scheme + add name+password sign-in
Scheme form "hermes:alice" was leaking into the UI (default display
mode + hero copy + placeholders). Drop it as a visible option: the
display-mode picker now offers bare / dotted / email and defaults to
bare so users just see "alice" in chat lists and settings. The
hermes: URI scheme stays available as an input form (parseHandle
still accepts it) — just not surfaced in the UI.

New sign-in path: a "Sign in with name + password" button on the
signin screen opens a two-field form that calls getBackup({password})
to unwrap the locally-saved mnemonic, then init({mnemonic, handle})
in one shot. This covers the "come back to the same device" case
without typing the full seed every time. From a new device the seed
phrase path is still there — the form hint says so.

Applied to both the desktop addon panel (TheseusNavigator/bundled-
addons/hermes/panel.html) and the hermes.x web messenger
(site-hermes-x/messenger/index.html) so they stay in step.
2026-10-22 02:27:23 +03:00
Local Dev
88b5f2473b hermes: monkey-patch scryptSync maxmem from the extracted lib
The main.js scrypt call also carries its own maxmem option now (commit
00286f76), but main.js lives inside app.asar and can't be hot-patched
onto an installed Theseus. lib/hermes.js lives at
resources/lib/hermes.mjs OUTSIDE the asar — patching it with the
module-level crypto.scryptSync override means a plain file-write onto
an installed Theseus fixes Create account without rebuilding and
reinstalling.

The patch guards itself with __hermesScryptPatched so repeated
loads of hermes.mjs (dev reload, test harnesses) don't re-wrap and
blow up the function reference chain.
2026-10-22 02:02:25 +03:00
Local Dev
47af393f22 hermes brand kit: inner V-fold now reads on every surface
Before: the on-sky lockup had a dark mark with dark cutouts — the fold
V-shape disappeared because the cutouts matched the mark colour instead
of the sky backdrop. Same on cream.

Now three per-surface mark variants, cutouts coloured to match the
expected background so it shows through:
  mark.svg          sky body  + navy cutouts   → dark panel
  mark-on-sky.svg   navy body + sky cutouts    → sky-blue surface
  mark-on-light.svg navy body + cream cutouts  → light surface

Logo lockups rebuilt the same way: logo-horizontal-on-sky.svg uses the
dark+sky mark variant, logo-horizontal-light.svg uses the dark+cream
mark variant, same for stacked.

Branding page's on-sky / on-light preview cells repointed at the new
variants. addon.json bumped to 0.2.6 (same mark, re-emitted from
pipeline for byte parity with the sites).
2026-10-22 00:14:54 +03:00
9 changed files with 1581 additions and 958 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -19,6 +19,7 @@ const { ipcMain } = require("electron");
const PANEL_METHODS = [
"status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup",
"publishCloudBackup", "fetchCloudBackup", "hasCloudBackup", "removeCloudBackup",
"getProfile", "updateProfile",
"getPhone", "setPhone", "clearPhone", "findByPhones",
"inbox", "send", "markThreadRead",

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -18,6 +18,26 @@ import {
verifyEvent, nip19, nip44,
} from "nostr-tools";
// --- scryptSync maxmem monkey-patch ----------------------------------------
// node:crypto defaults scryptSync's maxmem option to 32 MiB. The host-side
// mnemonic wrap uses N=2^15 r=8 p=1 which needs ~32 MiB exactly — Node throws
// MEMORY_LIMIT_EXCEEDED on the boundary. Lift the ceiling here so the Create
// account button stops erroring. Lives in this file (loaded dynamically from
// resources/lib/hermes.mjs, outside app.asar) so an installed Theseus can be
// hot-patched by replacing this file alone — a main.js fix would be stuck
// inside the asar until a new release ships.
import { createRequire as _createRequire } from "node:module";
const _requireFromHermes = _createRequire(import.meta.url);
const _nodeCryptoShared = _requireFromHermes("node:crypto");
const _origScryptSync = _nodeCryptoShared.scryptSync;
if (!_nodeCryptoShared.__hermesScryptPatched) {
_nodeCryptoShared.scryptSync = function (password, salt, keylen, options) {
const opts = Object.assign({ maxmem: 128 * 1024 * 1024 }, options || {});
return _origScryptSync.call(this, password, salt, keylen, opts);
};
_nodeCryptoShared.__hermesScryptPatched = true;
}
const enc = new TextEncoder();
// ---- key derivation --------------------------------------------------------
@ -301,6 +321,23 @@ export async function buildPhoneClaimEvent({ sk, phone } = {}) {
}
/** Deletion event (NIP-09, kind:5) that retracts earlier claim events by id. */
// NIP-78 kind:30078 parameterised replaceable event carrying a
// password-wrapped mnemonic. d-tag = `hermes.backup.v1/<16-byte hex of
// sha256(bareName)>` so a client can locate it from name + password alone.
// Content stays opaque to relays — only AES-GCM + scrypt protect the seed.
export const BACKUP_DTAG_PREFIX = "hermes.backup.v1/";
export function buildBackupEvent({ sk, dtag, content } = {}) {
if (!sk) throw new Error("sk required");
if (!dtag) throw new Error("dtag required");
return finalizeEvent({
kind: 30078,
tags: [["d", dtag]],
content: typeof content === "string" ? content : JSON.stringify(content || {}),
created_at: Math.floor(Date.now() / 1000),
}, sk);
}
export function buildPhoneRetractEvent({ sk, eventIds } = {}) {
if (!sk || !Array.isArray(eventIds) || !eventIds.length) throw new Error("sk + eventIds required");
return finalizeEvent({

141
main.js
View file

@ -3819,6 +3819,42 @@ function lockPanelView(view) {
if (/^(?:https?|bns):/i.test(url)) createTab(url);
});
}
// Right-click menu for sidebar web views (quick-links like WhatsApp /
// Telegram, and add-on panels). Sites can override their own context menu
// (WhatsApp Web does, and drops "Copy image"); listening at the Electron
// layer guarantees a native menu with image / link / copy actions.
function installPanelContextMenu(view) {
const wc = view.webContents;
wc.on("context-menu", (_e, p) => {
const items = [];
const haveMain = !!win && !win.isDestroyed();
if (p.linkURL) {
items.push(
{ label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } },
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.mediaType === "image" && p.srcURL) {
items.push(
{ label: "Open image in new tab", enabled: haveMain, click: () => { createTab(p.srcURL); try { win.focus(); } catch {} } },
{ label: "Save image as…", click: () => wc.downloadURL(p.srcURL) },
{ label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } },
{ label: "Copy image address", click: () => clipboard.writeText(p.srcURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
items.push(
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
{ label: "Reload", click: () => wc.reload() },
);
try { Menu.buildFromTemplate(items).popup({ window: win && !win.isDestroyed() ? win : undefined }); } catch {}
});
}
function addonIdForSender(sender) {
try {
const u = new URL(sender.getURL());
@ -5698,6 +5734,7 @@ function createWindow() {
// brand scrollbar the moment its DOM is ready.
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
lockPanelView(sidebar);
installPanelContextMenu(sidebar);
raise(sidebar);
styleScrollbars(sidebar.webContents);
sidebar.setVisible(false);
@ -5727,6 +5764,7 @@ function createWindow() {
if (url && /^(?:https?|bns):/i.test(url)) createTab(url);
return { action: "deny" };
});
installPanelContextMenu(quickPanel);
try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
raise(quickPanel);
styleScrollbars(quickPanel.webContents);
@ -5734,6 +5772,7 @@ function createWindow() {
// Left add-on panels: same preload and IPC surface as the right sidebar.
leftPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
lockPanelView(leftPanel);
installPanelContextMenu(leftPanel);
try { leftPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
raise(leftPanel);
styleScrollbars(leftPanel.webContents);
@ -10784,6 +10823,108 @@ global._hermesAPI = {
},
hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }),
// ---- cloud backup (opt-in B) ---------------------------------------------
// Publishes the password-wrapped mnemonic as a NIP-78 kind:30078 event
// keyed by d-tag = "hermes.backup.v1/<sha256(bareName)>". On a new device
// the user enters the same name + password; the client queries relays for
// the event, decrypts the wrap, and signs in without ever typing the seed
// phrase. Each ciphertext is encrypted with scrypt + aes-256-gcm (same
// params as hermesWrapMnemonic) so the password strength IS the backup
// strength — relays store opaque bytes.
publishCloudBackup: async ({ password } = {}) => {
if (!hermesState) return hErr("not initialised");
if (!password) return hErr("password required");
if (!hermesState.handle) return hErr("this account has no handle set — cloud backup needs one");
const localWrap = hermesLoadWrappedMnemonic();
if (!localWrap) return hErr("no local wrap to publish — only accounts created with a password can be backed up this way");
let mnemonic;
try { mnemonic = hermesUnwrapMnemonic(localWrap, password); }
catch { return hErr("wrong password — doesn't match the one you set at signup"); }
try {
const H = await loadHermesLib();
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
const wrap = hermesWrapMnemonic(mnemonic, password);
const bareName = hermesState.handle.toLowerCase().split(".")[0];
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
const dtag = H.BACKUP_DTAG_PREFIX + nameHash;
const signed = H.buildBackupEvent({
sk,
dtag,
content: { v: 1, handle: hermesState.handle, wrap },
});
const results = await hermesPublishEvent(signed, HERMES_DEFAULT_RELAYS);
const accepted = results.filter((r) => r.ok).length;
if (!accepted) return hErr("no relay accepted the backup — try again or add more relays");
hermesUpdateIdentityMeta({
cloudBackupAt: new Date().toISOString(),
cloudBackupEventId: signed.id,
});
return hOk({ accepted, total: results.length });
} catch (e) { return hErr(e); }
},
hasCloudBackup: () => {
const meta = hermesLoadIdentityMeta();
return hOk({ hasCloudBackup: !!(meta && meta.cloudBackupAt), at: meta && meta.cloudBackupAt || null });
},
removeCloudBackup: async () => {
if (!hermesState) return hErr("not initialised");
const meta = hermesLoadIdentityMeta();
const evId = meta && meta.cloudBackupEventId;
if (!evId) {
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
return hOk({ deleted: false, note: "no tracked backup event; cleared local marker" });
}
try {
const H = await loadHermesLib();
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
const retract = H.buildPhoneRetractEvent({ sk, eventIds: [evId] });
await hermesPublishEvent(retract, HERMES_DEFAULT_RELAYS);
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
return hOk({ deleted: true });
} catch (e) { return hErr(e); }
},
// Fetch + decrypt cloud backup for sign-in on a new device.
// Does NOT touch hermesState — the client calls init() with the returned
// mnemonic to actually sign in.
fetchCloudBackup: async ({ name, password } = {}) => {
if (!name || !password) return hErr("name and password required");
try {
const bareName = String(name).toLowerCase().split(".")[0].replace(/^hermes:/, "");
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
const dtag = "hermes.backup.v1/" + nameHash;
const found = await new Promise((resolve) => {
let best = null;
let pending = HERMES_DEFAULT_RELAYS.length;
const finish = () => resolve(best);
for (const url of HERMES_DEFAULT_RELAYS) {
const ws = new WebSocket(url);
const close = () => { try { ws.close(1000); } catch {}; if (--pending <= 0) finish(); };
const t = setTimeout(close, 5000);
ws.on("open", () => ws.send(JSON.stringify(["REQ", "b-" + Math.random().toString(36).slice(2, 8),
{ kinds: [30078], "#d": [dtag], limit: 1 }])));
ws.on("message", (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 30078) {
if (!best || msg[2].created_at > best.created_at) best = msg[2];
} else if (msg[0] === "EOSE") { clearTimeout(t); close(); }
});
ws.on("error", () => { clearTimeout(t); close(); });
}
});
if (!found) return hErr("no cloud backup found for \"" + name + "\"");
let payload;
try { payload = JSON.parse(found.content); } catch { return hErr("corrupt backup payload"); }
if (!payload || payload.v !== 1 || !payload.wrap) return hErr("unsupported backup version");
try {
const mnemonic = hermesUnwrapMnemonic(payload.wrap, password);
return hOk({ mnemonic, handle: payload.handle || null });
} catch { return hErr("wrong password"); }
} catch (e) { return hErr(e); }
},
// ---- phone discovery (opt-in) --------------------------------------------
// Publishes a kind:30078 claim event bound to a hash of the phone number,
// signed by the user's Hermes key. The phone number itself is normalised

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.102",
"version": "0.3.103",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",

View file

@ -0,0 +1,218 @@
#!/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);