diff --git a/the-matrix/index.html b/the-matrix/index.html index b5fac26..613844e 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -652,6 +652,24 @@
+ + + diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 3c25033..40e79ec 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -11,6 +11,7 @@ import { initInteraction, disposeInteraction, registerSlapTarget } from './inter import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initPaymentPanel } from './payment.js'; import { initSessionPanel } from './session.js'; +import { initVouchPanel } from './vouch.js'; import { initNostrIdentity } from './nostr-identity.js'; import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js'; import { setEdgeWorkerReady } from './ui.js'; @@ -46,6 +47,7 @@ function buildWorld(firstInit, stateSnapshot) { initWebSocket(scene); initPaymentPanel(); initSessionPanel(); + initVouchPanel(); void initNostrIdentity('/api'); warmupEdgeWorker(); onEdgeWorkerReady(() => setEdgeWorkerReady()); diff --git a/the-matrix/js/nostr-identity.js b/the-matrix/js/nostr-identity.js index b17b78a..68b514a 100644 --- a/the-matrix/js/nostr-identity.js +++ b/the-matrix/js/nostr-identity.js @@ -86,6 +86,33 @@ export function getPubkey() { return _pubkey; } export function getNostrToken() { return _isTokenValid() ? _token : null; } export function hasIdentity() { return !!_pubkey; } +/** + * signEvent — sign an arbitrary Nostr event template using NIP-07 or local key. + * Returns the signed event object, or null if signing fails. + */ +export async function signEvent(eventTemplate) { + if (_useNip07 && window.nostr) { + try { + return await window.nostr.signEvent(eventTemplate); + } catch (err) { + console.warn('[nostr] NIP-07 signEvent failed, trying local key', err); + } + } + + const privkeyBytes = _getPrivkeyBytes(); + if (!privkeyBytes) { + console.warn('[nostr] no private key available for signing'); + return null; + } + + try { + return finalizeEvent({ ...eventTemplate, pubkey: _pubkey }, privkeyBytes); + } catch (err) { + console.warn('[nostr] finalizeEvent failed', err); + return null; + } +} + /** * getOrRefreshToken — returns a valid token, refreshing if necessary. * Returns null if no identity is established. diff --git a/the-matrix/js/vouch.js b/the-matrix/js/vouch.js new file mode 100644 index 0000000..9895cbc --- /dev/null +++ b/the-matrix/js/vouch.js @@ -0,0 +1,168 @@ +/** + * vouch.js — Elite-tier Trust Panel for vouching newcomers. + * + * Visible only when the authenticated user has tier === 'elite'. + * Calls POST /api/identity/vouch with a signed Nostr event. + */ + +import { getOrRefreshToken, getPubkey, signEvent } from './nostr-identity.js'; +import { decode as nip19Decode, npubEncode } from 'nostr-tools/nip19'; + +const API = '/api'; + +let _panel = null; +let _tier = null; // cached tier from /identity/me + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function initVouchPanel() { + _panel = document.getElementById('vouch-panel'); + if (!_panel) return; + + document.getElementById('vouch-btn')?.addEventListener('click', _doVouch); + + // Check tier after identity is ready + window.addEventListener('nostr:identity-ready', () => _checkTier()); + + // Also check on init (identity may already be established) + if (getPubkey()) _checkTier(); +} + +// ── Tier check ──────────────────────────────────────────────────────────────── + +async function _checkTier() { + const token = await getOrRefreshToken(API); + if (!token) { + _setVisible(false); + return; + } + + try { + const res = await fetch(`${API}/identity/me`, { + headers: { 'X-Nostr-Token': token }, + }); + if (!res.ok) { _setVisible(false); return; } + + const data = await res.json(); + _tier = data.trust?.tier; + _setVisible(_tier === 'elite'); + } catch { + _setVisible(false); + } +} + +function _setVisible(show) { + if (_panel) _panel.style.display = show ? '' : 'none'; +} + +// ── Vouch action ────────────────────────────────────────────────────────────── + +async function _doVouch() { + const input = document.getElementById('vouch-pubkey-input'); + const raw = (input?.value || '').trim(); + if (!raw) { _setStatus('Enter an npub or hex pubkey.', '#994444'); return; } + + // Decode npub1... or accept raw 64-char hex + let hexPubkey; + try { + hexPubkey = _decodePubkey(raw); + } catch (err) { + _setStatus(err.message, '#994444'); + return; + } + + const myPubkey = getPubkey(); + if (hexPubkey === myPubkey) { + _setStatus('Cannot vouch for yourself.', '#994444'); + return; + } + + _setStatus('Signing vouch event...', '#ffaa00'); + _setBtn(true); + + try { + const token = await getOrRefreshToken(API); + if (!token) { _setStatus('No Nostr token — identify first.', '#994444'); return; } + + // Create and sign a Nostr event with ["p", voucheePubkey] tag + const eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', hexPubkey]], + content: `Vouching for ${hexPubkey}`, + }; + + const signedEvent = await signEvent(eventTemplate); + if (!signedEvent) { _setStatus('Signing failed.', '#994444'); return; } + + _setStatus('Submitting vouch...', '#ffaa00'); + + const res = await fetch(`${API}/identity/vouch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Nostr-Token': token, + }, + body: JSON.stringify({ voucheePubkey: hexPubkey, event: signedEvent }), + }); + + const data = await res.json(); + + if (res.status === 409) { + // Duplicate vouch + const shortNpub = _shortNpub(hexPubkey); + _setStatus(`Already vouched for ${shortNpub}.`, '#ffaa00'); + return; + } + + if (!res.ok) { + _setStatus(data.error || `Error ${res.status}`, '#994444'); + return; + } + + // Success + const shortNpub = _shortNpub(hexPubkey); + _setStatus(`\u2713 Vouched for ${shortNpub} \u2014 +20 trust pts granted`, '#22aa66'); + if (input) input.value = ''; + + } catch (err) { + _setStatus('Network error: ' + err.message, '#994444'); + } finally { + _setBtn(false); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _decodePubkey(input) { + // npub1... bech32 + if (input.startsWith('npub1')) { + const decoded = nip19Decode(input); + if (decoded.type !== 'npub') throw new Error('Invalid npub — wrong bech32 type.'); + return decoded.data; + } + // Raw 64-char hex + if (/^[0-9a-f]{64}$/i.test(input)) { + return input.toLowerCase(); + } + throw new Error('Enter a valid npub1... or 64-char hex pubkey.'); +} + +function _shortNpub(hexPubkey) { + try { + const npub = npubEncode(hexPubkey); + return npub.slice(0, 12) + '\u2026'; + } catch { + return hexPubkey.slice(0, 12) + '\u2026'; + } +} + +function _setStatus(msg, color = '#22aa66') { + const el = document.getElementById('vouch-status'); + if (el) { el.textContent = msg; el.style.color = color; } +} + +function _setBtn(disabled) { + const el = document.getElementById('vouch-btn'); + if (el) el.disabled = disabled; +}