[claude] feat: elite-tier Trust Panel vouch UI (#50) #60

Merged
claude merged 1 commits from claude/issue-50 into main 2026-03-23 01:56:28 +00:00
4 changed files with 215 additions and 0 deletions

View File

@@ -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 ─────────────────────────────────────────────── -->

View File

@@ -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());

View File

@@ -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
View 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;
}