[claude] feat: elite-tier Trust Panel vouch UI (#50) #60
@@ -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