// hub.js — shared client helpers for the Nostr Voice Hub. // NIP-07: the browser extension (Alby/nos2x) signs WITHOUT ever exposing the // secret key. We only ask for: the pubkey, a signed event, a signed NIP-98. // No key material ever leaves the browser. const HUB = (() => { function haveNip07() { return !!(window.nostr && (window.nostr.getPublicKey || window.nostr.nip07)); } async function getPubkey() { if (!window.nostr || typeof window.nostr.getPublicKey !== 'function') { throw new Error('No NIP-07 extension (Alby, nos2x, or similar) detected.'); } return window.nostr.getPublicKey(); } // Build a signed event via NIP-07. kind 1 for a note; content = text. async function signEvent(kind, content, extraTags) { if (!window.nostr || typeof window.nostr.signEvent !== 'function') { throw new Error('No NIP-07 extension capable of signing events.'); } const pubkey = await getPubkey(); const created_at = Math.floor(Date.now() / 1000); const tags = extraTags || []; // signEvent receives the full event template; extension fills id + sig. const evt = await window.nostr.signEvent({ kind, pubkey, created_at, tags, content }); return evt; } // NIP-98 HTTP auth: sign a 27235 event bound to this URL, return "Nostr ". async function nip98Auth(url, method = 'POST') { const pubkey = await getPubkey(); const created_at = Math.floor(Date.now() / 1000); const evt = await signEvent(27235, '', [['u', url], ['method', method]]); const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(evt)))); return 'Nostr ' + b64; } // Short display for a hex pubkey (first/last 6). function shortPub(pubkey) { return pubkey ? pubkey.slice(0, 6) + '…' + pubkey.slice(-6) : ''; } // Convert an integer pubkey (hex) to a bech32 npub for display. // Minimal npub encoder (NIP-19). Falls back to 'npub1' if anything fails. function toNpub(pubkeyHex) { try { return bech32Encode('npub', hexToBytes(pubkeyHex)); } catch (e) { return 'npub1' + pubkeyHex; } } const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; function hexToBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); return out; } function polymod(values) { const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; let chk = 1; for (const v of values) { const b = chk >> 25; chk = (chk & 0x1ffffff) << 5 ^ v; for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i]; } return chk; } function expandHrp(hrp) { return hrp.split('').map(c => c.charCodeAt(0) >> 5).concat([0], hrp.split('').map(c => c.charCodeAt(0) & 31)); } function convert(data, from, to, pad) { let acc = 0, bits = 0, out = []; const maxv = (1 << to) - 1; for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } } if (pad && bits > 0) out.push((acc << (to - bits)) & maxv); return out; } function bech32Encode(hrp, data) { const bytes = convert(data, 8, 5, true); const values = expandHrp(hrp).concat(bytes); const plm = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ 1; const checksum = []; for (let i = 0; i < 6; i++) checksum.push((plm >> (5 * (5 - i))) & 31); return hrp + '1' + bytes.concat(checksum).map(c => CHARSET[c]).join(''); } // Fetch with an Authorization header, return parsed JSON. async function authedGet(url) { const auth = await nip98Auth(url, 'GET'); const res = await fetch(url, { headers: { Authorization: auth } }); return res.json(); } async function authedPost(url, body) { const auth = await nip98Auth(url, 'POST'); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: auth }, body: JSON.stringify(body), }); return res.json(); } function timeAgo(ts) { const s = Math.floor(Date.now() / 1000) - ts; if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } return { haveNip07, getPubkey, signEvent, nip98Auth, shortPub, toNpub, authedGet, authedPost, timeAgo }; })();