feat: add elite-tier Trust Panel vouch UI in Workshop sidebar
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -652,6 +652,24 @@
|
||||
</div>
|
||||
|
||||
<div id="session-error"></div>
|
||||
|
||||
<!-- ── Trust Panel (elite-only vouch UI) ──────────────────────────── -->
|
||||
<div id="vouch-panel" style="display:none; margin-top:28px; border-top:1px solid #0e2318; padding-top:16px">
|
||||
<div class="panel-label" style="color:#bb8822; letter-spacing:2px">TRUST PANEL — ELITE</div>
|
||||
<p style="font-size:10px;color:#665522;margin-top:6px;line-height:1.6;letter-spacing:0.5px">
|
||||
VOUCH FOR A NEWCOMER TO GRANT +20 TRUST POINTS.
|
||||
</p>
|
||||
<input type="text" id="vouch-pubkey-input"
|
||||
class="session-amount-input"
|
||||
style="width:100%;margin-top:10px;font-size:11px;color:#bb8822;border-color:#33280e"
|
||||
placeholder="npub1... or hex pubkey"
|
||||
autocomplete="off" autocorrect="off" spellcheck="false" />
|
||||
<button class="panel-btn" id="vouch-btn"
|
||||
style="border-color:#bb8822;color:#bb8822;margin-top:10px">
|
||||
VOUCH
|
||||
</button>
|
||||
<div id="vouch-status" style="font-size:11px;margin-top:8px;min-height:16px;color:#22aa66"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FPS crosshair ─────────────────────────────────────────────── -->
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
168
the-matrix/js/vouch.js
Normal file
168
the-matrix/js/vouch.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user