hermes.x/messenger: wrap the Theseus panel as a stand-alone webapp

The Hermes sidebar panel from TheseusNavigator/bundled-addons/hermes/
runs as a web page here with no changes to the UI code — all the host
API it needs (bridge.invoke, bridge.on, storage) is provided by the
shim.js next to it. The two pieces together give visitors without
Theseus a working browser messenger: paste an nsec or 12/24-word
mnemonic, resolve a .x name on-chain, send a NIP-17 gift-wrapped DM
over a WebSocket to the recipient's declared relays.

What landed:

- index.html — copy of the panel's panel.html with the site topnav
  wrapped above it (so Overview / Branding / home stay reachable) and
  the panel's own .bar + .screen in a .panel-root flex container. The
  .screen stays position:absolute inset:44px 0 0 0 — just anchored to
  the panel root now instead of the viewport. Shared.css provides the
  topnav styling; the panel's inline <style> keeps the rest. Three
  assets/mark.svg references rewritten to ../assets/mark.svg so the
  injected <base href="/bns/hermes.x/messenger/"> resolves them to the
  site-root assets/ directory, not messenger/assets/.

- shim.js — ~540 lines implementing window.silentmode.{invoke,on,off,
  storage,sidebar}. invoke handlers: init(nsec | mnemonic | create),
  status, inbox, send, contactsList, contactsAdd, markThreadRead,
  getProfile (stub), signOut, canUseVault (always false in a browser),
  phone/backup/updateProfile stubs for the Theseus-only paths. Crypto
  is pure nostr-tools from jsdelivr ESM — nip44 v2 for the content key,
  wrapChat / unwrapChat mirroring TheseusNavigator/lib/hermes.js exactly.
  Keys: nsec1 via nip19, BIP-39 via WebCrypto PBKDF2+HKDF with info
  "silentmode/messenger/0" (same path as the Theseus host).

  Name resolution hits /api/name/<name> for np + nr; the shim parses np
  as hex(64) or npub1, nr as whitespace/comma-separated wss:// URLs,
  with wss://nos.lol as the default when the owner publishes no nr.

  localStorage falls back to an in-memory Map when it throws — the
  navigate.st relay serves BNS sites under a CSP sandbox without
  allow-same-origin, which disables localStorage. Signed in state
  survives the tab's lifetime; reload re-prompts for the key. Loaded
  outside the sandbox (Theseus direct origin), localStorage works and
  state persists across sessions.
This commit is contained in:
Local Dev 2026-10-22 02:21:56 +03:00
parent a9831f8f12
commit 86e67d05e7
2 changed files with 1509 additions and 242 deletions

File diff suppressed because it is too large Load diff

536
messenger/shim.js Normal file
View file

@ -0,0 +1,536 @@
// Browser host-shim for the Hermes messenger panel.
//
// The panel UI (panel.html inside Theseus) talks to the Theseus main process
// over an IPC bridge exposed as window.silentmode. This shim re-implements
// that bridge in a plain browser tab: NIP-17 wrap/unwrap with nostr-tools,
// Nostr pubkey + relay lookup via navigate.st/api/name, inbox + contacts in
// localStorage.
//
// Why this works at all: the panel calls bridge.invoke(name, payload) and
// awaits a promise, and subscribes with bridge.on(channel, cb). Every op is
// a pure function of the panel's state, the user's secret key, and a WSS
// connection — no Node, no filesystem, no Electron. The real Theseus host
// adds vault binding, mnemonic persistence and backup; the browser can
// never promise any of that, so the shim stubs those paths and surfaces an
// honest error when the panel asks.
//
// Scope v0 (2026-10-21):
// - nsec1… paste and BIP-39 12/24-word paste both go in `mnemonicInput`
// - one default relay: wss://nos.lol (matches Theseus HERMES_DEFAULT_RELAYS)
// - send resolves `alice.x` → np+nr via /api/name
// - receive via a single long-lived REQ subscription
// - inbox + contacts in localStorage, scoped by `hermes.<pkHex>.*`
// - no profile fetch (truncated pkHex shows); phone/backup/vault stubbed
//
// Keep this file plain classic script. The panel's own <script> is inline,
// so window.silentmode has to exist by the time this HTML parses it. The
// nostr-tools import is lazy via dynamic import() inside init() — that way
// the module load happens AFTER html parse but BEFORE the user has had time
// to paste a key.
(function () {
"use strict";
const GATEWAY = location.origin; // navigate.st in prod
const DEFAULT_RELAYS = ["wss://nos.lol"];
const HERMES_PURPOSE = "silentmode/messenger/0";
const KIND_CHAT = 14, KIND_SEAL = 13, KIND_GIFT_WRAP = 1059;
const TWO_DAYS_S = 2 * 24 * 60 * 60;
// ---- module state ------------------------------------------------------
let nt = null; // nostr-tools module, lazy-loaded
let identity = null; // { sk: Uint8Array, pkHex, npub }
let relays = DEFAULT_RELAYS.slice(); // live set after init
let sockets = new Map(); // url -> WebSocket (receive subs)
let seenWrapIds = new Set(); // guard against duplicate inserts
const emitter = new EventTarget(); // panel subscriptions
const skeyFor = (pkHex) => `hermes.${pkHex}`;
// The navigate.st relay serves BNS sites under a CSP sandbox without
// allow-same-origin, which disables localStorage. That is a deliberate
// sealed-view policy, not a bug: a page that cannot persist a key cannot
// leak one on close. We fall back to an in-memory store so the panel keeps
// working for the tab's lifetime; reload clears everything. When the same
// page is loaded inside Theseus (direct origin), localStorage works and
// state persists per-pubkey across sessions.
const memStore = new Map();
const hasLocalStorage = (() => {
try { localStorage.setItem("__hs_probe", "1"); localStorage.removeItem("__hs_probe"); return true; }
catch { return false; }
})();
const storeGet = (k) => {
if (hasLocalStorage) { try { return localStorage.getItem(k); } catch { return null; } }
return memStore.has(k) ? memStore.get(k) : null;
};
const storeSet = (k, v) => {
if (hasLocalStorage) { try { localStorage.setItem(k, v); return; } catch {} }
memStore.set(k, v);
};
// ---- lazy nostr-tools --------------------------------------------------
async function loadNT() {
if (nt) return nt;
nt = await import("https://cdn.jsdelivr.net/npm/nostr-tools@2.10.4/+esm");
return nt;
}
// ---- key derivation (BIP-39 + HKDF) via WebCrypto ---------------------
const enc = new TextEncoder();
async function bip39ToSeed(mnemonic, passphrase = "") {
const km = await crypto.subtle.importKey("raw",
enc.encode(String(mnemonic).normalize("NFKD").trim()),
"PBKDF2", false, ["deriveBits"]);
const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD"));
return new Uint8Array(await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512));
}
async function seedToPurposeRoot(seedBytes, purpose) {
const key = await crypto.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]);
const info = enc.encode(`silentmode/${purpose}`);
return new Uint8Array(await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
}
// ---- parse the input the user pasted ----------------------------------
// Three forms accepted:
// • nsec1… → NIP-19 decode → 32-byte sk
// • 12/24 BIP-39 words → PBKDF2+HKDF path (matches Theseus's
// nostrKeyFromMnemonic with purpose "messenger/0")
// • 64-char hex → raw sk, no decoding
async function parseIdentityInput(input) {
await loadNT();
const v = String(input || "").trim();
if (!v) throw new Error("paste an nsec1… or a 12/24-word mnemonic");
if (/^nsec1[0-9a-z]+$/i.test(v)) {
const d = nt.nip19.decode(v);
if (d.type !== "nsec" || !(d.data instanceof Uint8Array) || d.data.length !== 32) {
throw new Error("nsec decoded to unexpected shape");
}
return d.data;
}
if (/^[0-9a-f]{64}$/i.test(v)) {
const hex = v.toLowerCase();
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
const words = v.split(/\s+/).filter(Boolean);
if (words.length === 12 || words.length === 15 || words.length === 18 ||
words.length === 21 || words.length === 24) {
const seed = await bip39ToSeed(v, "");
return await seedToPurposeRoot(seed, "messenger/0");
}
throw new Error("expected an nsec1… key or 12/24-word mnemonic");
}
// ---- relay socket pool -------------------------------------------------
// One socket per relay. On open we send our REQ subscription for inbound
// kind:1059 gift wraps addressed to our pubkey. Reconnect on close with
// simple backoff — browser tabs that sleep still re-establish on wake.
function openRelay(url) {
if (sockets.has(url)) return sockets.get(url);
const sub = { url, ws: null, backoff: 1000, connected: false };
const connect = () => {
try {
const ws = new WebSocket(url);
sub.ws = ws;
ws.addEventListener("open", () => {
sub.connected = true;
sub.backoff = 1000;
emitStatus();
if (!identity) return;
ws.send(JSON.stringify(["REQ", "hermes-inbox", {
kinds: [KIND_GIFT_WRAP],
"#p": [identity.pkHex],
// The 2-day jitter means wraps for events sent "now" can carry
// created_at up to TWO_DAYS_S in the past. Ask for ~7 days of
// backfill on open so a reconnect doesn't lose recent messages.
since: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60,
}]));
});
ws.addEventListener("message", (ev) => onRelayMessage(url, ev.data));
ws.addEventListener("close", () => {
sub.connected = false; sub.ws = null;
emitStatus();
setTimeout(connect, sub.backoff);
sub.backoff = Math.min(30000, sub.backoff * 2);
});
ws.addEventListener("error", () => { try { ws.close(); } catch {} });
} catch (e) {
setTimeout(connect, sub.backoff);
sub.backoff = Math.min(30000, sub.backoff * 2);
}
};
connect();
sockets.set(url, sub);
return sub;
}
function closeAllRelays() {
for (const sub of sockets.values()) {
try { sub.ws && sub.ws.close(1000); } catch {}
}
sockets.clear();
}
async function onRelayMessage(url, data) {
let msg; try { msg = JSON.parse(data); } catch { return; }
if (!Array.isArray(msg)) return;
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === KIND_GIFT_WRAP) {
await handleWrap(msg[2]);
}
// NOTICE / OK / EOSE / CLOSED all ignored for v0
}
async function handleWrap(wrap) {
if (!identity) return;
if (seenWrapIds.has(wrap.id)) return;
seenWrapIds.add(wrap.id);
try {
await loadNT();
const { getPublicKey, verifyEvent, nip44 } = nt;
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
const decrypt = (sk, pkHex, cipher) => nip44.v2.decrypt(cipher, convKey(sk, pkHex));
if (!verifyEvent(wrap)) throw new Error("gift wrap signature invalid");
const seal = JSON.parse(decrypt(identity.sk, wrap.pubkey, wrap.content));
if (seal.kind !== KIND_SEAL) throw new Error("inner not a seal");
if (!verifyEvent(seal)) throw new Error("seal signature invalid");
const rumor = JSON.parse(decrypt(identity.sk, seal.pubkey, seal.content));
if (rumor.kind !== KIND_CHAT) throw new Error("inner not a chat");
if (rumor.pubkey !== seal.pubkey) throw new Error("rumor pubkey mismatch");
recordMessage({
id: wrap.id,
peerPkHex: rumor.pubkey.toLowerCase(),
senderPkHex: rumor.pubkey.toLowerCase(),
meHex: identity.pkHex,
text: String(rumor.content || ""),
createdAt: Number(rumor.created_at) || 0,
direction: "in",
});
emitter.dispatchEvent(new CustomEvent("hermes-message", { detail: {} }));
} catch (e) {
// A relay serves everyone's wraps; most aren't ours. Decrypt errors on
// other-recipient wraps are the common case here, not an app bug.
}
}
// ---- storage helpers ---------------------------------------------------
function loadInbox() {
if (!identity) return [];
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".inbox") || "[]"); }
catch { return []; }
}
function saveInbox(list) {
if (!identity) return;
try { storeSet(skeyFor(identity.pkHex) + ".inbox", JSON.stringify(list)); } catch {}
}
function recordMessage(m) {
const inbox = loadInbox();
if (inbox.some((x) => x.id === m.id)) return;
inbox.push(m);
inbox.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
saveInbox(inbox);
}
function loadContacts() {
if (!identity) return [];
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".contacts") || "[]"); }
catch { return []; }
}
function saveContacts(list) {
if (!identity) return;
try { storeSet(skeyFor(identity.pkHex) + ".contacts", JSON.stringify(list)); } catch {}
}
function loadReadState() {
if (!identity) return {};
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".read") || "{}"); }
catch { return {}; }
}
function saveReadState(st) {
if (!identity) return;
try { storeSet(skeyFor(identity.pkHex) + ".read", JSON.stringify(st)); } catch {}
}
// ---- name resolution ---------------------------------------------------
// /api/name/<label> returns the chain entry (records.np, records.nr live
// alongside h/u/s3/ip/p). The signed-manifest apex merge is server-side,
// so a response here already reflects anything the owner published off-chain.
async function resolveTarget(input) {
await loadNT();
const v = String(input || "").trim();
if (!v) throw new Error("empty recipient");
if (/^[0-9a-f]{64}$/i.test(v)) {
return { pkHex: v.toLowerCase(), relays: relays.slice(), canonicalName: null };
}
if (/^npub1[0-9a-z]+$/i.test(v)) {
const d = nt.nip19.decode(v);
if (d.type !== "npub" || typeof d.data !== "string") throw new Error("bad npub");
return { pkHex: d.data.toLowerCase(), relays: relays.slice(), canonicalName: null };
}
const lower = v.replace(/^hermes:/i, "").toLowerCase();
const name = /\./.test(lower) ? lower : (lower + ".x");
const r = await fetch(`${GATEWAY}/api/name/${encodeURIComponent(name)}`,
{ signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`${name}: no on-chain registration`);
const j = await r.json();
const rec = (j && j.records) || {};
const np = rec.np ? parseNp(rec.np) : null;
if (!np) throw new Error(`${name}: no np record on chain (owner hasn't published a Nostr key)`);
const nr = parseNr(rec.nr, relays);
return { pkHex: np, relays: nr, canonicalName: name };
}
function parseNp(raw) {
const v = String(raw || "").trim();
if (/^[0-9a-f]{64}$/i.test(v)) return v.toLowerCase();
if (/^npub1[0-9a-z]+$/i.test(v)) {
const d = nt.nip19.decode(v);
if (d.type === "npub" && typeof d.data === "string") return d.data.toLowerCase();
}
return null;
}
function parseNr(raw, defaults) {
if (!raw || typeof raw !== "string") return defaults.slice();
const out = []; const seen = new Set();
for (const tok of raw.split(/[,\s]+/)) {
const url = tok.trim();
if (!/^wss?:\/\//i.test(url)) continue;
if (seen.has(url)) continue;
seen.add(url); out.push(url);
}
return out.length ? out : defaults.slice();
}
// ---- status / emit -----------------------------------------------------
function status() {
const conns = [...sockets.values()];
const connected = conns.filter((s) => s.connected).length;
return {
ok: true,
ready: !!identity,
pkHex: identity && identity.pkHex,
npub: identity && identity.npub,
handle: null,
// Panel reads flat relaysConnected / relaysTotal; keep the nested
// relays object for any future caller that wants structured data too.
relaysConnected: connected,
relaysTotal: conns.length,
relays: { total: conns.length, connected },
};
}
function emitStatus() {
emitter.dispatchEvent(new CustomEvent("hermes-status-update", { detail: status() }));
}
// ---- invoke handlers ---------------------------------------------------
const handlers = {
async canUseVault() {
// There is no Theseus vault in a plain browser tab. Panel disables the
// vault button when `available` is false.
return { available: false };
},
async status() {
return status();
},
async init(opts) {
try {
let sk;
if (opts && opts.create) {
await loadNT();
sk = nt.generateSecretKey();
} else {
sk = await parseIdentityInput(opts && opts.mnemonic);
}
await loadNT();
const pkHex = nt.getPublicKey(sk);
identity = { sk, pkHex, npub: nt.nip19.npubEncode(pkHex) };
// One socket per relay, pulls in since-7d backlog on open
for (const url of relays) openRelay(url);
emitStatus();
return { ok: true, pkHex: identity.pkHex, npub: identity.npub, handle: null };
} catch (e) {
return { ok: false, err: (e && e.message) || String(e) };
}
},
async signOut() {
identity = null;
closeAllRelays();
seenWrapIds = new Set();
emitStatus();
return { ok: true };
},
async inbox() {
if (!identity) return { ok: true, messages: [] };
// Panel wants: { ok, messages: [{ id, pkHex, direction, text, createdAt }] }
// where pkHex is the PEER's pubkey (the one who is NOT me).
const raw = loadInbox();
return { ok: true, messages: raw };
},
async contactsList() {
if (!identity) return { ok: true, contacts: [] };
return { ok: true, contacts: loadContacts() };
},
async contactsAdd(payload) {
if (!identity) return { ok: false, err: "signed out" };
try {
const target = await resolveTarget(payload && payload.name);
const list = loadContacts();
if (!list.some((c) => c.pkHex === target.pkHex)) {
list.push({
pkHex: target.pkHex,
name: target.canonicalName || payload.name,
relays: target.relays,
addedAt: Math.floor(Date.now() / 1000),
});
saveContacts(list);
}
return { ok: true, contact: list.find((c) => c.pkHex === target.pkHex) };
} catch (e) {
return { ok: false, err: (e && e.message) || String(e) };
}
},
async markThreadRead(payload) {
if (!identity) return { ok: true };
const pk = String(payload && payload.peerPkHex || "").toLowerCase();
if (!pk) return { ok: true };
const upTo = Number(payload && payload.upToCreatedAt) || Math.floor(Date.now() / 1000);
const st = loadReadState();
st[pk] = Math.max(upTo, Number(st[pk] || 0));
saveReadState(st);
return { ok: true };
},
async send(payload) {
if (!identity) return { ok: false, err: "signed out" };
try {
await loadNT();
const to = String(payload && payload.to || "");
const text = String(payload && payload.text || "");
if (!text.trim()) return { ok: false, err: "empty message" };
const target = await resolveTarget(to);
const wrap = buildWrap({ senderSk: identity.sk, recipientPkHex: target.pkHex, text });
// Publish to the recipient's declared relays + our own set, so our
// own future reconnect pulls the wrap back from somewhere we read from.
const publishTo = Array.from(new Set([...(target.relays || []), ...relays]));
const results = await Promise.allSettled(publishTo.map((url) => publishOne(url, wrap)));
const okCount = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
if (!okCount) throw new Error("no relay accepted the message");
// Mirror the sent message locally so the panel's own thread view
// shows it immediately — a relay won't echo our own wrap back to us.
recordMessage({
id: wrap.id,
peerPkHex: target.pkHex,
senderPkHex: identity.pkHex,
meHex: identity.pkHex,
text,
createdAt: Math.floor(Date.now() / 1000),
direction: "out",
});
emitter.dispatchEvent(new CustomEvent("hermes-message", { detail: {} }));
return { ok: true, acceptedBy: okCount, totalRelays: publishTo.length };
} catch (e) {
return { ok: false, err: (e && e.message) || String(e) };
}
},
// ---- stubs the panel asks about but we don't implement in v0 -------
async getProfile() { return { ok: true, profile: null }; },
async hasBackup() { return { ok: true, hasBackup: false }; },
async getBackup() { return { ok: false, err: "backup is only in the Theseus desktop messenger" }; },
async getPhone() { return { ok: true, phone: null }; },
async setPhone() { return { ok: false, err: "phone linking is only in the Theseus desktop messenger" }; },
async clearPhone() { return { ok: true }; },
async updateProfile() { return { ok: false, err: "profile editing is only in the Theseus desktop messenger" }; },
"open-messages"() { return { ok: true }; },
};
// ---- wrap: identical to lib/hermes.js wrapChat ------------------------
function buildWrap({ senderSk, recipientPkHex, text }) {
const { getPublicKey, getEventHash, finalizeEvent, generateSecretKey, nip44 } = nt;
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
const encrypt = (sk, pkHex, plain) => nip44.v2.encrypt(plain, convKey(sk, pkHex));
const senderPkHex = getPublicKey(senderSk);
const jitteredNow = () => Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS_S);
const rumor = {
kind: KIND_CHAT,
pubkey: senderPkHex,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPkHex]],
content: text,
};
rumor.id = getEventHash(rumor);
const seal = finalizeEvent({
kind: KIND_SEAL,
created_at: jitteredNow(),
tags: [],
content: encrypt(senderSk, recipientPkHex, JSON.stringify(rumor)),
}, senderSk);
const ephemeralSk = generateSecretKey();
return finalizeEvent({
kind: KIND_GIFT_WRAP,
created_at: jitteredNow(),
tags: [["p", recipientPkHex]],
content: encrypt(ephemeralSk, recipientPkHex, JSON.stringify(seal)),
}, ephemeralSk);
}
// Publish an event to one relay with an OK confirmation + 6 s timeout.
// A one-shot socket per publish keeps the inbound subscription clean and
// avoids racing OK responses between concurrent sends on the same socket.
function publishOne(url, ev) {
return new Promise((resolve) => {
let settled = false;
const done = (ok) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(!!ok); };
let ws;
try { ws = new WebSocket(url); }
catch { return resolve(false); }
const t = setTimeout(() => done(false), 6000);
ws.addEventListener("open", () => ws.send(JSON.stringify(["EVENT", ev])));
ws.addEventListener("message", (m) => {
let msg; try { msg = JSON.parse(m.data); } catch { return; }
if (Array.isArray(msg) && msg[0] === "OK" && msg[1] === ev.id) {
clearTimeout(t); done(msg[2] === true);
}
});
ws.addEventListener("error", () => { clearTimeout(t); done(false); });
ws.addEventListener("close", () => { clearTimeout(t); done(false); });
});
}
// ---- public bridge -----------------------------------------------------
window.silentmode = {
async invoke(name, payload) {
const h = handlers[name];
if (!h) return { ok: false, err: `unknown invoke: ${name}` };
try { return await h(payload); }
catch (e) { return { ok: false, err: (e && e.message) || String(e) }; }
},
on(channel, cb) {
const listener = (ev) => { try { cb(ev.detail); } catch {} };
emitter.addEventListener(channel, listener);
return () => emitter.removeEventListener(channel, listener);
},
off(channel, cb) {
// panel uses on()'s returned unsubscribe; this is a convenience alias
emitter.removeEventListener(channel, cb);
},
storage: {
async get(k) {
try { return JSON.parse(storeGet("hermes.shim." + k) || "null"); }
catch { return null; }
},
async set(k, v) {
try { storeSet("hermes.shim." + k, JSON.stringify(v)); } catch {}
},
},
// The panel reads `.sidebar` as an optional capability — null means we're
// not inside Theseus's sidebar. Keeps the "pop out of sidebar" button
// from throwing when panel tries to call it.
sidebar: null,
};
})();