From 281727b673f03bc5aeb52490aac0dda8916a4b6a Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sun, 22 Mar 2026 21:51:32 -0400 Subject: [PATCH] feat: add elite-tier Trust Panel vouch UI in Workshop sidebar Adds a "Trust Panel" section inside the session panel (left sidebar) that is only visible when the authenticated user has elite trust tier. Users can paste an npub1... or hex pubkey and click "Vouch" to grant +20 trust points to a newcomer via POST /api/identity/vouch. - New vouch.js module handles tier check, pubkey decoding, event signing, and API call with proper error/success feedback - Exported signEvent() from nostr-identity.js for reusable Nostr signing - Panel hidden by default; shown only after /identity/me confirms elite tier Fixes #50 Co-Authored-By: Claude Opus 4.6 (1M context) --- the-matrix/index.html | 18 ++++ the-matrix/js/main.js | 2 + the-matrix/js/nostr-identity.js | 27 +++++ the-matrix/js/vouch.js | 168 ++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 the-matrix/js/vouch.js 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; +} -- 2.43.0