diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/index.js | 2 | ||||
| -rw-r--r-- | src/features/names.js | 84 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 27 |
3 files changed, 65 insertions, 48 deletions
diff --git a/src/features/index.js b/src/features/index.js index 8f3e5d1..70f8421 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -20,5 +20,5 @@ import { nwcFeature } from './nwc.js'; // names sits between ark and zaps: a pasted user@domain resolves over DNS // (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), nostrLoginFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/names.js b/src/features/names.js index 970a690..33bddd7 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -12,6 +12,7 @@ // record falls back to the Lightning-address flow (zaps feature). import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js'; +import { nip98Header } from '../nostr-login.js'; import { qrSvg } from '../qr.js'; import { isArkAddress } from './ark.js'; import { getNetwork } from '../api.js'; @@ -24,6 +25,25 @@ const AUTH_KIND = 21353; export function namesFeature(ctx) { const { h, ui, render, wallet, hook, toast, copyBtn } = ctx; + // The wallet's own key signs registrar requests: it's the one key that is + // always available — offline, in the background, with no signer attached. + const walletSigner = () => ({ + pubkey: wallet.nostrPubkey && wallet.nostrPubkey(), + signEvent: async (e) => wallet.nostrSign(e), + }); + + async function post(path, method, payload, signer) { + const url = `${REGISTRAR}${path}`; + const body = JSON.stringify(payload); + const auth = await nip98Header(signer || walletSigner(), url, method, body); + const r = await fetch(url, { + method, headers: { 'content-type': 'application/json', authorization: auth }, body, + }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + const load = () => wallet.loadFeatureState('names', {}); const save = (st) => wallet.saveFeatureState('names', st); @@ -35,31 +55,19 @@ export function namesFeature(ctx) { return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null; } - async function claim(name) { + async function claim(name, { signer, manager } = {}) { const uri = await currentUri(); if (!uri) throw new Error(t('namesNeedArk')); - const auth = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'register', name, uri, domain: DOMAIN }), - }); - if (!auth) throw new Error('wallet cannot sign'); - const r = await fetch(`${REGISTRAR}/register`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth }), - }); - const j = await r.json(); - if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + const j = await post('/register', 'POST', { + name, uri, domain: DOMAIN, + // when the login identity claims its own npub name, it nominates the + // wallet key to keep the record updated afterwards + ...(manager ? { manager } : {}), + }, signer); const prev = load().name; save({ name, domain: DOMAIN, uri, updated: Date.now() }); if (prev && prev !== name) { - const bye = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'delete', name: prev, domain: DOMAIN }), - }); - fetch(`${REGISTRAR}/register`, { - method: 'DELETE', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth: bye }), - }).catch(() => {}); + post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {}); } return j; } @@ -67,30 +75,10 @@ export function namesFeature(ctx) { async function release() { const st = load(); if (!st.name) return; - const auth = wallet.nostrSign({ - kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [], - content: JSON.stringify({ action: 'delete', name: st.name, domain: st.domain || DOMAIN }), - }); - await fetch(`${REGISTRAR}/register`, { - method: 'DELETE', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ auth }), - }).catch(() => {}); + await post('/register', 'DELETE', { name: st.name, domain: st.domain || DOMAIN }).catch(() => {}); save({}); } - // An imported seed doesn't know its username — the registrar does. Ask by - // the wallet's nostr pubkey and adopt what it says before deciding whether - // onboarding needs to prompt. - let checked = false; // lookup completed (gate stays closed until then) - async function lookupMine() { - try { - const pk = wallet.nostrPubkey && wallet.nostrPubkey(); - if (!pk) return; - const r = await fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json()); - if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri }); - } catch {} - } - // Everyone gets an address without being asked: the default username is a // prefix of the wallet's npub — unique, boring, and free. Custom names are // an optional change (and may cost something one day, to keep squatters @@ -101,8 +89,18 @@ export function namesFeature(ctx) { let st = load(); if (!st.name) { await lookupMine(); st = load(); } if (!st.name) { - const npub = wallet.nostrNpub && wallet.nostrNpub(); - if (npub) { await claim(npub.slice(0, 20)); st = load(); } + // The address people see should be the identity they know the user + // by: their real npub when a nostr account is linked, else the + // wallet's own. Both are claimed the same way; the registrar checks + // that an npub-shaped name really belongs to its claimant. + const linked = hook('nostrLoginIdentity'); + const npub = (linked && linked.npub) || (wallet.nostrNpub && wallet.nostrNpub()); + if (npub) { + await claim(npub.slice(0, 20), linked + ? { signer: linked.signer, manager: wallet.nostrPubkey() } + : {}); + st = load(); + } } checked = true; render(); diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 60e3759..c5369e9 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -22,6 +22,11 @@ export function nostrLoginFeature(ctx) { const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); + // The signer that opened this session, kept so the names feature can claim + // the user's real npub as their payment address (and nominate the wallet + // key as manager, so later updates work without the signer). + let live = null; + const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); }; @@ -40,11 +45,16 @@ export function nostrLoginFeature(ctx) { busy(false); return; } - await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + // Same wallet however you log in: a key-derived seed is published too, + // so an extension login later finds this wallet instead of making a new + // one. Costs nothing — anyone with the key could derive it anyway. + if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {}); + live = signer; + await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); busy(false); } catch (e) { fail(e); } - finally { if (signer && signer.close && !ui.nostrLoginNew) signer.close(); } } async function createForSigner() { @@ -55,7 +65,9 @@ export function nostrLoginFeature(ctx) { const mnemonic = newMnemonic(); await publishWalletBackup(st.signer, { mnemonic }); ui.nostrLoginNew = null; - await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey }); + live = st.signer; + await ctx.openMnemonic(mnemonic, '', { nostrPubkey: st.signer.pubkey }); + save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now() }); toast(t('nlCreated')); busy(false); } catch (e) { fail(e); } @@ -75,11 +87,11 @@ export function nostrLoginFeature(ctx) { throw new Error(t('nlAlreadyLinked')); } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); + live = signer; save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); toast(t('nlLinked')); busy(false); } catch (e) { fail(e); } - finally { if (signer && signer.close) signer.close(); } } // ---- UI --------------------------------------------------------------- @@ -157,6 +169,13 @@ export function nostrLoginFeature(ctx) { return { id: 'nostrlogin', + // The nostr identity this session is logged in as, for features that + // should speak as the user (the payment address defaults to this npub). + nostrLoginIdentity() { + const st = load(); + if (!st.pubkey) return null; + return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; + }, unlockExtra() { return unlockExtra(); }, settingsCards() { return [settingsCard()]; }, }; |
