v1.2.5 - Add nip17_admin_enabled config toggle (default off) to prevent OOM from NIP-17 gift wrap decryption flood

This commit is contained in:
Your Name
2026-02-23 08:58:10 -04:00
parent 81d44c3d8c
commit 1adabdbc4e
20 changed files with 2533 additions and 117 deletions
+1
View File
@@ -11,3 +11,4 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools
.test_keys
+178
View File
@@ -1291,6 +1291,184 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
/* background-color: var(--secondary-color); */
}
/* ================================
WEB OF TRUST (WoT) STYLES
================================ */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #ff0000);
color: white;
border-color: var(--accent-color, #ff0000);
}
.wot-level-description {
font-size: 12px;
color: var(--primary-color);
margin-top: 5px;
font-style: italic;
opacity: 0.8;
}
/* Dark mode adjustments for WoT */
body.dark-mode .wot-indicator.wot-found { background: #28a745; }
body.dark-mode .wot-indicator.wot-missing { background: #dc3545; }
body.dark-mode .wot-indicator.wot-unknown { background: #6c757d; }
/* ================================
ADMIN ACCESS GATE STYLES
================================ */
/* Access Denied Overlay */
.access-denied-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
display: none;
justify-content: center;
align-items: center;
}
.access-denied-content {
background: var(--card-bg);
border: 2px solid #dc3545;
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 500px;
box-shadow: 0 10px 40px rgba(220, 53, 69, 0.3);
}
.access-denied-icon {
font-size: 64px;
margin-bottom: 20px;
}
.access-denied-content h2 {
color: #dc3545;
font-size: 32px;
margin-bottom: 20px;
letter-spacing: 2px;
}
.access-denied-message {
font-size: 16px;
color: var(--primary-color);
margin-bottom: 10px;
line-height: 1.5;
}
.access-denied-submessage {
font-size: 14px;
color: var(--muted-color);
margin-bottom: 30px;
font-style: italic;
}
.access-denied-logout-btn {
background: #dc3545;
color: white;
border: none;
padding: 12px 40px;
font-size: 16px;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.access-denied-logout-btn:hover {
background: #c82333;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
/* Admin Verification Loading Overlay */
.admin-verification-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 9998;
display: none;
justify-content: center;
align-items: center;
}
.admin-verification-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 40px 60px;
text-align: center;
max-width: 450px;
}
.admin-verification-content h3 {
color: var(--accent-color);
font-size: 20px;
margin-bottom: 15px;
}
.admin-verification-content p {
color: var(--muted-color);
font-size: 14px;
margin-top: 15px;
}
/* Spinner Animation */
.spinner {
width: 50px;
height: 50px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dark mode adjustments */
body.dark-mode .access-denied-content {
background: var(--card-bg);
}
body.dark-mode .admin-verification-content {
background: var(--card-bg);
}
.subscription-detail-row:hover {
background-color: var(--muted-color);
}
+55 -7
View File
@@ -67,6 +67,17 @@
</div>
</div>
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
<div class="access-denied-content">
<div class="access-denied-icon"></div>
<h2>ACCESS DENIED</h2>
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
</div>
</div>
<!-- DATABASE STATISTICS Section -->
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
@@ -251,6 +262,7 @@
</div>
<!-- Auth Rules Management - Moved after configuration -->
<!-- AUTH RULES MANAGEMENT SECTION -->
<div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header">
AUTH RULES MANAGEMENT
@@ -273,16 +285,11 @@ AUTH RULES MANAGEMENT
</table>
</div>
<!-- Simplified Auth Rule Input Section -->
<!-- Auth Rule Input Section -->
<div id="authRuleInputSections" style="display: block;">
<!-- Combined Pubkey Auth Rule Section -->
<div class="input-group">
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
@@ -296,10 +303,51 @@ AUTH RULES MANAGEMENT
BLACKLIST</button>
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
</div>
</div>
</div>
<!-- WEB OF TRUST SECTION -->
<div class="section flex-section" id="wotSection" style="display: none;">
<div class="section-header">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount"></span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
+411 -18
View File
@@ -39,6 +39,11 @@ let countdownSeconds = 10;
let currentPage = 'statistics'; // Default page
let sideNavOpen = false;
// Admin verification state
let isAdminVerified = false;
let adminVerificationTimeout = null;
let pendingAdminVerification = false;
// SQL Query state
let pendingSqlQueries = new Map();
@@ -306,7 +311,10 @@ async function restoreAuthenticationState(pubkey) {
loadUserProfile();
// Automatically set up relay connection (but don't show admin sections yet)
await setupAutomaticRelayConnection();
await setupAutomaticRelayConnection(false);
// Verify admin access before showing admin sections
verifyAdminAccess();
console.log('✅ Authentication state restored successfully');
}
@@ -422,8 +430,8 @@ async function initializeApp() {
if (wasAlreadyLoggedIn) {
console.log('User was already logged in, showing profile in header');
showProfileInHeader();
// Show admin sections since user is already authenticated and relay is connected
updateAdminSectionsVisibility();
// Admin verification will be triggered by restoreAuthenticationState
// which calls verifyAdminAccess() after setting up relay connection
} else {
console.log('No existing authentication found, showing login modal');
showLoginModal();
@@ -453,11 +461,11 @@ function handleAuthEvent(event) {
showProfileInHeader();
loadUserProfile();
// Automatically set up relay connection and show admin sections
setupAutomaticRelayConnection(true);
// Automatically set up relay connection
setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
// Verify admin access before showing admin sections
verifyAdminAccess();
} else if (error) {
console.log(`Authentication error: ${error}`);
@@ -471,11 +479,25 @@ function handleLogoutEvent() {
userPubkey = null;
isLoggedIn = false;
currentConfig = null;
isAdminVerified = false;
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Clear any pending admin verification
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
// Hide access denied overlay if showing
hideAccessDeniedOverlay();
// Hide admin verification loading if showing
hideAdminVerificationLoading();
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
showLoginModal();
@@ -486,12 +508,115 @@ function handleLogoutEvent() {
console.log('Logout event handled successfully');
}
// Verify admin access by sending a system_status command and waiting for response
async function verifyAdminAccess() {
console.log('=== VERIFYING ADMIN ACCESS ===');
// Update visibility of admin sections based on login and relay connection status
if (!isLoggedIn || !isRelayConnected) {
console.log('Cannot verify admin access - not logged in or not connected to relay');
showAccessDeniedOverlay();
return;
}
pendingAdminVerification = true;
// Show a loading indicator while verifying
showAdminVerificationLoading();
// Send system_status command to verify admin access
try {
await sendAdminCommand(['system_command', 'system_status']);
console.log('Admin verification command sent');
} catch (error) {
console.error('Failed to send admin verification command:', error);
// Continue with timeout - the command might have been queued
}
// Set timeout for admin verification (5 seconds)
adminVerificationTimeout = setTimeout(() => {
if (pendingAdminVerification) {
console.log('⛔ Admin verification timeout - user is not admin');
pendingAdminVerification = false;
isAdminVerified = false;
hideAdminVerificationLoading();
showAccessDeniedOverlay();
}
}, 5000);
}
// Show loading indicator while verifying admin access
function showAdminVerificationLoading() {
// Create loading overlay if it doesn't exist
let loadingOverlay = document.getElementById('admin-verification-loading');
if (!loadingOverlay) {
loadingOverlay = document.createElement('div');
loadingOverlay.id = 'admin-verification-loading';
loadingOverlay.className = 'admin-verification-overlay';
loadingOverlay.innerHTML = `
<div class="admin-verification-content">
<div class="spinner"></div>
<h3>Verifying Administrator Access...</h3>
<p>Please wait while we verify your admin privileges.</p>
</div>
`;
document.body.appendChild(loadingOverlay);
}
loadingOverlay.style.display = 'flex';
}
// Hide admin verification loading overlay
function hideAdminVerificationLoading() {
const loadingOverlay = document.getElementById('admin-verification-loading');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
}
// Show access denied overlay for non-admin users
function showAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'flex';
}
// Also hide any admin sections that might be showing
hideAllAdminSections();
}
// Hide access denied overlay
function hideAccessDeniedOverlay() {
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
if (accessDeniedOverlay) {
accessDeniedOverlay.style.display = 'none';
}
}
// Hide all admin sections
function hideAllAdminSections() {
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'nip17DMSection',
'sqlQuerySection'
];
sections.forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.style.display = 'none';
}
});
}
// Update visibility of admin sections based on login, relay connection, and admin verification status
function updateAdminSectionsVisibility() {
const shouldShow = isLoggedIn && isRelayConnected;
const shouldShow = isLoggedIn && isRelayConnected && isAdminVerified;
// If logged in and connected, show the current page, otherwise hide all sections
// If logged in, connected, and admin verified, show the current page
if (shouldShow) {
// Show the current page
switchPage(currentPage);
@@ -499,12 +624,13 @@ function updateAdminSectionsVisibility() {
// Load data for the current page
loadCurrentPageData();
} else {
// Hide all sections when not logged in or not connected
// Hide all sections when not logged in, not connected, or not admin
const sections = [
'databaseStatisticsSection',
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'nip17DMSection',
'sqlQuerySection'
];
@@ -543,6 +669,10 @@ function loadCurrentPageData() {
loadAuthRules().catch(error => {
console.log('Auto-load auth rules failed: ' + error.message);
});
// Load WoT status
loadWotStatus().catch(error => {
console.log('Auto-load WoT status failed: ' + error.message);
});
break;
// Other pages don't need initial data loading
}
@@ -1717,6 +1847,31 @@ function handleSystemCommandResponse(responseData) {
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle admin verification via system_status response
if (responseData.command === 'system_status' && pendingAdminVerification) {
console.log('✅ Admin verification successful - received system_status response');
// Clear the timeout
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
}
pendingAdminVerification = false;
isAdminVerified = true;
// Hide loading overlay
hideAdminVerificationLoading();
// Show admin sections now that we're verified
updateAdminSectionsVisibility();
// Auto-enable monitoring when admin logs in
autoEnableMonitoring();
log('Administrator access verified', 'INFO');
}
// Handle delete auth rule responses
if (responseData.command === 'delete_auth_rule') {
if (responseData.status === 'success') {
@@ -1741,11 +1896,224 @@ function handleSystemCommandResponse(responseData) {
}
}
// Handle WoT status and sync responses
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
handleWotStatusResponse(responseData);
}
if (typeof logTestEvent === 'function') {
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
}
}
// ================================
// WEB OF TRUST (WoT) FUNCTIONS
// ================================
// Load WoT status from relay using admin API
async function loadWotStatus() {
try {
log('Loading WoT status via admin API...', 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to load WoT status');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for WoT status
const command_array = ["system_command", "wot_status"];
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt WoT status command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
log('Sending WoT status query to relay...', 'INFO');
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT status query sent successfully', 'INFO');
} catch (error) {
log(`Failed to load WoT status: ${error.message}`, 'ERROR');
}
}
// Set WoT level via config_set
async function setWotLevel(level) {
try {
log(`Setting WoT level to ${level}...`, 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to set WoT level');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Send config_set for wot_enabled
const command_array = ["config_set", "wot_enabled", String(level)];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt config_set command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log(`WoT level set to ${level} - sync will trigger automatically`, 'INFO');
// Refresh status after a delay to allow sync to complete
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log(`Failed to set WoT level: ${error.message}`, 'ERROR');
}
}
// Force WoT sync from admin's kind 3 event
async function syncWot() {
try {
log('Triggering WoT sync from kind 3...', 'INFO');
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to sync WoT');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
// Create command array for WoT sync
const command_array = ["system_command", "wot_sync"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt WoT sync command');
}
// Create single kind 23456 admin event
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT sync command sent', 'INFO');
// Refresh status after a delay
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log(`Failed to sync WoT: ${error.message}`, 'ERROR');
}
}
// Handle WoT status response and update UI
function handleWotStatusResponse(responseData) {
console.log('=== WoT STATUS RESPONSE ===', responseData);
const kind3Indicator = document.getElementById('wotKind3Indicator');
const whitelistCount = document.getElementById('wotWhitelistCount');
const levelDesc = document.getElementById('wotLevelDescription');
// Update kind 3 indicator
if (kind3Indicator) {
if (responseData.admin_kind3_exists) {
kind3Indicator.textContent = 'Found ✓';
kind3Indicator.className = 'wot-indicator wot-found';
} else {
kind3Indicator.textContent = 'Not Found ✗';
kind3Indicator.className = 'wot-indicator wot-missing';
}
}
// Update whitelist count
if (whitelistCount) {
whitelistCount.textContent = responseData.wot_whitelist_count !== undefined ? responseData.wot_whitelist_count : '—';
}
// Update level buttons
const level = responseData.wot_enabled !== undefined ? responseData.wot_enabled : 0;
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
const btn = document.getElementById(id);
if (btn) {
if (i === level) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
});
// Update description
const descriptions = [
'Level 0: Open relay — anyone can read and write',
'Level 1: Write-only — only followed pubkeys can publish events',
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
];
if (levelDesc) {
levelDesc.textContent = descriptions[level] || descriptions[0];
}
// Enable/disable sync button based on kind 3 existence
const syncBtn = document.getElementById('wotSyncBtn');
if (syncBtn) {
syncBtn.disabled = !responseData.admin_kind3_exists;
}
log(`WoT status: level=${level}, kind3_exists=${responseData.admin_kind3_exists}, whitelist_count=${responseData.wot_whitelist_count}`, 'INFO');
}
// Handle auth rule modification responses
function handleAuthRuleResponse(responseData) {
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
@@ -2206,6 +2574,7 @@ let editingAuthRule = null;
// DOM elements for auth rules
const authRulesSection = document.getElementById('authRulesSection');
const wotSection = document.getElementById('wotSection');
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
const authRulesTableBody = document.getElementById('authRulesTableBody');
@@ -2222,6 +2591,12 @@ function showAuthRulesSection() {
updateAuthRulesStatus('ready');
log('Auth rules section is now available', 'INFO');
}
// Also show WoT section
if (wotSection) {
wotSection.style.display = 'block';
loadWotStatus();
log('WoT section is now available', 'INFO');
}
}
// Hide auth rules section on logout
@@ -2241,6 +2616,11 @@ function hideAuthRulesSection() {
editingAuthRule = null;
log('Auth rules section hidden', 'INFO');
}
// Also hide WoT section
if (wotSection) {
wotSection.style.display = 'none';
log('WoT section hidden', 'INFO');
}
}
// Update auth rules status indicator (removed - no status element)
@@ -4728,6 +5108,7 @@ function switchPage(pageName) {
'subscriptionDetailsSection',
'div_config',
'authRulesSection',
'wotSection',
'relayEventsSection',
'nip17DMSection',
'sqlQuerySection'
@@ -4740,22 +5121,34 @@ function switchPage(pageName) {
}
});
// Show selected section
// Show selected section(s)
const pageMap = {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'authorization': 'authRulesSection',
'relay-events': 'relayEventsSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
};
const targetSectionId = pageMap[pageName];
if (targetSectionId) {
const targetSection = document.getElementById(targetSectionId);
if (targetSection) {
targetSection.style.display = 'block';
// Authorization page shows both authRulesSection and wotSection
if (pageName === 'authorization') {
const authSection = document.getElementById('authRulesSection');
const wotSectionEl = document.getElementById('wotSection');
if (authSection) {
authSection.style.display = 'block';
}
if (wotSectionEl) {
wotSectionEl.style.display = 'block';
loadWotStatus();
}
} else {
const targetSectionId = pageMap[pageName];
if (targetSectionId) {
const targetSection = document.getElementById(targetSectionId);
if (targetSection) {
targetSection.style.display = 'block';
}
}
}
+20 -2
View File
@@ -70,6 +70,24 @@ while [[ $# -gt 0 ]]; do
;;
--test-keys|-t)
USE_TEST_KEYS=true
# Read keys from .test_keys file
if [ -f ".test_keys" ]; then
echo "Reading test keys from .test_keys file..."
# Source the file to get the variables
source .test_keys
# Remove any single quotes from the values
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
else
echo "ERROR: .test_keys file not found"
echo "Please create a .test_keys file with the following format:"
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
exit 1
fi
shift
;;
--debug-level=*)
@@ -336,8 +354,8 @@ fi
cd build
# Start relay in background and capture its PID
if [ "$USE_TEST_KEYS" = true ]; then
echo "Using deterministic test keys for development..."
./$(basename $BINARY_PATH) -a 6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3 -r 1111111111111111111111111111111111111111111111111111111111111111 --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
echo "Using test keys from .test_keys file..."
./$(basename $BINARY_PATH) -a "$ADMIN_KEY" -r "$RELAY_KEY" --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
elif [ -n "$RELAY_ARGS" ]; then
echo "Starting relay with custom configuration..."
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
+312
View File
@@ -0,0 +1,312 @@
# Event Tags Denormalization Plan
## Problem Statement
The relay is at 99% CPU with 1,178 WebSocket connections and ~120 new REQ subscriptions per minute. The root cause is that every tag-filtered REQ query (e.g., `#g`, `#e`, `#p`) uses `json_each(json(tags))` with `json_extract()` — a correlated subquery that parses the JSON tags column for every candidate row. With geohash-based subscriptions from a location app generating constant connection churn, this creates unsustainable CPU load.
### Evidence
- `EXPLAIN QUERY PLAN` shows: `CORRELATED SCALAR SUBQUERY → SCAN json_each VIRTUAL TABLE`
- 8,077 subscription creates/hour but only 442 active at any time (connection churn)
- Only 56 events stored/hour — the load is entirely from **read queries**, not writes
- 14,819 events match the kind filter; each query parses JSON tags on candidate rows
## Solution: Denormalized `event_tags` Table
Replace `json_each()` queries with indexed lookups on a separate `event_tags` table. This is the standard approach used by strfry, nostream, and other production Nostr relays.
### Performance Impact
| Metric | Before | After |
|--------|--------|-------|
| Tag query type | `json_each()` correlated subquery | Indexed JOIN/subquery |
| Complexity per query | O(rows × tags_per_event) JSON parsing | O(log n) B-tree lookup |
| Expected CPU reduction | 99% | <10% for same traffic |
## Files to Modify
| File | Changes |
|------|---------|
| `src/sql_schema.h` | Add `event_tags` table, indexes, cascade triggers; bump to schema v12 |
| `src/main.c` | Add `store_event_tags()`, update `handle_req_message()` tag filter SQL, move config reads out of row loop, add startup tag population |
| `src/nip009.c` | Add `DELETE FROM event_tags` alongside event deletions |
| `src/dm_admin.c` | Downgrade NIP-17 decryption failure log level |
| `src/websockets.c` | Downgrade NIP-17 error log to DEBUG at lines 780 and 1488 |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Add the `event_tags` table after the events table definition. Bump schema version to 12.
```sql
-- Denormalized event tags for fast indexed lookups
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row
CREATE TABLE event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL,
tag_value TEXT NOT NULL,
tag_index INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Primary lookup index: find events by tag name + value
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
-- Reverse lookup: find all tags for an event (for cleanup)
CREATE INDEX idx_event_tags_event ON event_tags(event_id);
-- Composite index for common query pattern: tag + kind (via join)
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);
```
**Key design decisions:**
- `ON DELETE CASCADE` handles cleanup when events are deleted (NIP-09, replaceable events)
- `tag_index` stores the position within the tags array (useful for ordered tag access)
- Only the first two elements of each tag are indexed (`tag_name` = `$[0]`, `tag_value` = `$[1]`) — this covers all standard Nostr tag filters (`#e`, `#p`, `#t`, `#g`, `#d`, etc.)
- `PRAGMA foreign_keys = ON` is already in the schema, so CASCADE will work
### 2. Store Tags: `src/main.c` — New `store_event_tags()` Function
Add a new function called after successful event INSERT in `store_event()`:
```c
// Insert denormalized tags into event_tags table for fast indexed lookups
int store_event_tags(const char* event_id, cJSON* tags) {
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
return 0; // Not an error if no tags
}
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
return -1;
}
int tag_index = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(name) && cJSON_IsString(value)) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, tag_index);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
}
}
}
tag_index++;
}
sqlite3_finalize(stmt);
return 0;
}
```
**Call site** — in `store_event()` at line ~784, after `monitoring_on_event_stored()`:
```c
// After successful event storage, insert denormalized tags
store_event_tags(cJSON_GetStringValue(id), tags);
```
### 3. Update REQ Query Builder: `src/main.c` — `handle_req_message()`
Replace the `json_each()` tag filter at lines 1243-1270 with an `event_tags` subquery:
**Before** (current code at line 1244):
```c
snprintf(sql_ptr, remaining,
" AND EXISTS (SELECT 1 FROM json_each(json(tags)) "
"WHERE json_extract(value, '$[0]') = ? "
"AND json_extract(value, '$[1]') IN (");
```
**After** (new code):
```c
snprintf(sql_ptr, remaining,
" AND id IN (SELECT event_id FROM event_tags "
"WHERE tag_name = ? AND tag_value IN (");
```
The rest of the parameter binding code (lines 1248-1270) stays the same — the bind params are identical (`tag_name` then `tag_value(s)`). Only the SQL template changes.
The closing parenthesis changes from `"))` to `"))` — same syntax, just different semantics.
### 4. NIP-09 Delete Cascade: `src/nip009.c`
The `ON DELETE CASCADE` foreign key handles this automatically. When `DELETE FROM events WHERE id = ?` executes, SQLite will automatically delete matching rows from `event_tags`. **No code changes needed in nip009.c** as long as `PRAGMA foreign_keys = ON` is set (it already is in the schema).
However, verify the replaceable/addressable event triggers in the schema also cascade properly. The existing triggers at schema lines 97-119 use `DELETE FROM events WHERE ...` which will trigger the CASCADE.
### 5. Performance Fix: Move Config Reads Out of Row Loop
In `handle_req_message()` at lines 1400-1401, `get_config_bool()` is called **inside the `while (sqlite3_step())` loop** — meaning it executes a SQLite query for every row returned. Move these before the loop:
**Before** (inside loop):
```c
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ...
int expiration_enabled = get_config_bool("expiration_enabled", 1); // SQLite query per row!
int filter_responses = get_config_bool("expiration_filter", 1); // SQLite query per row!
```
**After** (before loop):
```c
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// ... use cached values
```
### 6. Log Level Fixes
#### NIP-17 Decryption Failure — `src/websockets.c` lines 780 and 1488
Change from `DEBUG_ERROR` to `DEBUG_INFO`:
```c
// Before:
DEBUG_ERROR("NIP-17 admin message processing failed");
// After:
DEBUG_INFO("NIP-17 admin message processing failed");
```
This is expected behavior when non-admin gift wraps arrive — not an error condition.
#### Duplicate Event INSERT — `src/main.c` line 741
The `DEBUG_ERROR` at line 741 fires before the CONSTRAINT check at line 746. Suppress it for constraint violations:
```c
// Before (line 738-741):
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
// After:
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
if (rc != SQLITE_CONSTRAINT) {
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
}
```
### 7. Startup Tag Population
Since you said no migration code is needed (fresh deploy), the `event_tags` table will start empty and populate as new events arrive. Existing events won't have tags in the lookup table.
However, to avoid a period where old events don't appear in tag-filtered queries, add a one-time population function that runs at startup:
```c
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
```
Call this during startup, after database initialization but before accepting connections.
## Architecture Diagram
```mermaid
flowchart TD
subgraph "Current: O(n) per query"
A1["REQ with #g filter"] --> B1["SQL: SELECT ... WHERE EXISTS"]
B1 --> C1["json_each(json(tags))"]
C1 --> D1["json_extract per row"]
D1 --> E1["🔴 Full JSON parse per candidate row"]
end
subgraph "New: O(log n) per query"
A2["REQ with #g filter"] --> B2["SQL: SELECT ... WHERE id IN"]
B2 --> C2["event_tags table"]
C2 --> D2["idx_event_tags_lookup"]
D2 --> E2["🟢 B-tree index lookup"]
end
subgraph "Write Path (unchanged speed)"
F["EVENT arrives"] --> G["store_event()"]
G --> H["INSERT INTO events"]
H --> I["store_event_tags()"]
I --> J["INSERT INTO event_tags\n(one row per tag)"]
end
subgraph "Delete Path (automatic)"
K["NIP-09 DELETE"] --> L["DELETE FROM events"]
L --> M["ON DELETE CASCADE"]
M --> N["event_tags rows auto-deleted"]
end
```
## Testing Strategy
1. Run existing test suite: `tests/run_all_tests.sh`
2. Specifically run NIP tests that use tag filters: `tests/run_nip_tests.sh`
3. Verify tag-filtered REQ queries return correct results
4. Verify NIP-09 deletion cascades to event_tags
5. Verify replaceable event triggers cascade to event_tags
6. Monitor CPU usage after deployment with same traffic pattern
## Risk Assessment
- **Low risk**: The `event_tags` table is additive — it doesn't change the events table structure
- **Low risk**: `ON DELETE CASCADE` is a well-tested SQLite feature
- **Low risk**: The SQL change in `handle_req_message()` is a drop-in replacement (same bind params)
- **Medium risk**: Startup population on a large database could take a few seconds — but with only 55K events, this should complete in under 1 second
- **Write overhead**: Each event INSERT now also inserts ~5 rows into event_tags — negligible at 56 events/hour
+298
View File
@@ -0,0 +1,298 @@
# Web of Trust (WoT) Implementation Plan
## Feature Description
When enabled, the relay restricts access to pubkeys that the admin follows. The admin's kind 3 (contact list) event is used as the source of truth — all `p` tags in that event become whitelisted pubkeys. The admin themselves is always whitelisted.
Single config variable `wot_enabled` with three levels:
| Value | Mode | Effect |
|-------|------|--------|
| `0` | Off | Open relay — anyone can read and write |
| `1` | Write-only | Only followed pubkeys can **publish** events. Anyone can read/subscribe. |
| `2` | Full | Only followed pubkeys can **publish AND subscribe**. Requires NIP-42 auth for subscriptions. Eliminates subscription churn from anonymous connections. |
## How It Works
### WoT Sync Flow
```mermaid
flowchart TD
A["Admin publishes kind 3 event\n- contact list -"] --> B{wot_enabled > 0?}
B -->|No| C["Store event normally"]
B -->|Yes| D["Extract p tags from kind 3"]
D --> E["Clear existing WoT whitelist rules"]
E --> F["Insert new whitelist rules\nfor each followed pubkey"]
F --> G["Enable auth_enabled"]
G --> H{wot_enabled == 2?}
H -->|Yes| I["Enable nip42_auth_required_subscriptions"]
H -->|No| J["Done - write-only restriction"]
```
### Event Publishing Flow — Write Restriction (wot_enabled >= 1)
```mermaid
flowchart TD
A["EVENT message arrives"] --> B{auth_enabled?}
B -->|No| C["Accept event"]
B -->|Yes| D["check_database_auth_rules - pubkey -"]
D --> E{Pubkey in whitelist\nor wot_whitelist?}
E -->|Yes| C
E -->|No| F{Any whitelist rules exist?}
F -->|Yes| G["REJECT: not whitelisted"]
F -->|No| C
```
### Subscription Flow — Read Restriction (wot_enabled == 2)
```mermaid
flowchart TD
A["REQ message arrives"] --> B{wot_enabled == 2?}
B -->|No| C["Allow subscription"]
B -->|Yes| D{NIP-42 authenticated?}
D -->|No| E["Send AUTH challenge"]
D -->|Yes| F["check_database_auth_rules\nusing authenticated_pubkey"]
F --> G{Pubkey in whitelist\nor wot_whitelist?}
G -->|Yes| C
G -->|No| H["REJECT: not authorized\nfor subscriptions"]
```
## Design Decisions
### Leveraging Existing Infrastructure
The relay already has everything needed:
1. **`auth_rules` table** — stores whitelist/blacklist rules with `rule_type`, `pattern_type`, `pattern_value`
2. **`check_database_auth_rules()`** in [`request_validator.c:529`](src/request_validator.c:529) — already implements the logic: "if whitelist rules exist and pubkey is not whitelisted, deny"
3. **`add_auth_rule_from_config()`** / **`remove_auth_rule_from_config()`** in [`config.c`](src/config.c:2082) — already manage auth rules
4. **`event_tags` table** — can efficiently query `p` tags from kind 3 events
5. **Config system**`auth_enabled` flag already controls whether auth rules are checked
6. **NIP-42 auth**`nip42_auth_required_subscriptions` already gates REQ access, `pss->authenticated_pubkey` stores the authenticated pubkey
### WoT-Specific Whitelist Rules
To distinguish WoT-generated whitelist rules from manually-added ones, we use a new `rule_type` value: `wot_whitelist`. This allows:
- Clearing all WoT rules without affecting manual whitelist/blacklist rules
- Querying WoT status separately
- The existing `check_database_auth_rules()` function already checks for `rule_type = 'whitelist'` — we need to also match `wot_whitelist` in the whitelist check
### Trigger Mechanism
The WoT sync happens when:
1. **A kind 3 event from the admin is stored** — detected in `store_event()` after successful INSERT
2. **Startup** — if `wot_enabled > 0`, sync from the most recent admin kind 3 event in the database
3. **Admin DM command**`wot sync` to force a manual resync
### What Gets Whitelisted
- All pubkeys in `p` tags of the admin's kind 3 event
- The admin pubkey itself (always whitelisted)
- The relay pubkey (always whitelisted — for admin DM responses)
### What Is NOT Blocked
Even with WoT enabled, these are always allowed:
- Admin events (kind 23456) — already bypassed in [`request_validator.c:303`](src/request_validator.c:303)
- NIP-42 auth events (kind 22242) — already bypassed in [`request_validator.c:318`](src/request_validator.c:318)
- Kind 3 events from the admin — needed to update the follow list itself
- Kind 1059 gift wraps addressed to the relay — needed for admin DMs
## Files to Modify
| File | Changes |
|------|---------|
| `src/default_config_event.h` | Add `wot_enabled` config key (default: `0`) |
| `src/config.c` | Add `wot_sync_from_admin_kind3()` function |
| `src/main.c` | Add WoT trigger in `store_event()` when admin kind 3 is stored, add startup WoT sync |
| `src/request_validator.c` | Update whitelist SQL to also match `wot_whitelist` rule type |
| `src/sql_schema.h` | Update `auth_rules` CHECK constraint to include `wot_whitelist` |
| `src/websockets.c` | Add WoT pubkey check after NIP-42 auth check in REQ handler |
| `src/dm_admin.c` | Add `wot 0`, `wot 1`, `wot 2`, `wot sync`, `wot status` DM commands |
## Detailed Changes
### 1. Schema: `src/sql_schema.h`
Update the `auth_rules` table CHECK constraint to allow `wot_whitelist`:
```sql
-- Before:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required'))
-- After:
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist'))
```
### 2. Config: `src/default_config_event.h`
Add WoT configuration:
```c
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
```
### 3. Core WoT Sync Function: `src/config.c`
New function `wot_sync_from_admin_kind3()`:
```c
int wot_sync_from_admin_kind3(void) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) return 0; // WoT disabled
// 1. Get admin pubkey from config
// 2. Query event_tags for p tags from admin's latest kind 3 event:
// SELECT DISTINCT et.tag_value FROM event_tags et
// JOIN events e ON et.event_id = e.id
// WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p'
// ORDER BY e.created_at DESC
// 3. BEGIN TRANSACTION
// 4. DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'
// 5. INSERT wot_whitelist for admin pubkey
// 6. INSERT wot_whitelist for relay pubkey
// 7. For each p tag: INSERT INTO auth_rules (rule_type, pattern_type, pattern_value)
// VALUES ('wot_whitelist', 'pubkey', ?)
// 8. COMMIT
// 9. Set auth_enabled = true
// 10. If wot_level == 2: set nip42_auth_required_subscriptions = true
// 11. Log count of whitelisted pubkeys
return 0;
}
```
### 4. Trigger on Kind 3 Store: `src/main.c`
In `store_event()`, after successful INSERT and after `store_event_tags()`:
```c
// Check if this is a kind 3 event from the admin — trigger WoT sync
if ((int)cJSON_GetNumberValue(kind) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
```
### 5. Startup WoT Sync: `src/main.c`
In `main()`, after `populate_event_tags_from_existing()`:
```c
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
wot_sync_from_admin_kind3();
}
```
### 6. Update Whitelist Check: `src/request_validator.c`
Update the whitelist SQL queries to also match `wot_whitelist`:
```c
// Before:
"SELECT rule_type FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
// After:
"SELECT rule_type FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
```
And the whitelist-exists check:
```c
// Before:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
// After:
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
```
### 7. REQ Handler WoT Check: `src/websockets.c`
When `wot_enabled == 2`, add a pubkey whitelist check **after** the existing NIP-42 auth check in the REQ handler. The existing code at line 903 already requires NIP-42 auth when `nip42_auth_required_subscriptions` is set. We add a WoT check right after:
```c
// Existing NIP-42 auth check (line 903):
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
// ... send AUTH challenge or NOTICE ...
return 0;
}
// NEW: WoT read restriction check (wot_enabled == 2)
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated — check if their pubkey is in the WoT whitelist
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != NOSTR_SUCCESS) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
return 0;
}
}
```
### 8. Admin DM Commands: `src/dm_admin.c`
Add plain text DM commands:
| Command | Action |
|---------|--------|
| `wot 0` or `wot off` | Disable WoT, delete all `wot_whitelist` rules, reset `nip42_auth_required_subscriptions` |
| `wot 1` or `wot write` | Write-only WoT — sync follow list, set `auth_enabled=true` |
| `wot 2` or `wot full` | Full WoT — sync follow list, set `auth_enabled=true`, set `nip42_auth_required_subscriptions=true` |
| `wot sync` | Force resync from admin's kind 3 event |
| `wot status` | Show WoT level (0/1/2), count of whitelisted pubkeys |
### 9. NIP-11 Update
When WoT is enabled (level 1 or 2), the relay should indicate this in NIP-11 relay info. Add to the `limitation` object:
```json
"auth_required": true
```
## Edge Cases
1. **Admin has no kind 3 event in database**: WoT sync does nothing, logs a warning. No whitelist rules created = open relay.
2. **Admin updates follow list**: New kind 3 event triggers full resync — old WoT rules are cleared, new ones inserted. This is atomic (transaction).
3. **WoT disabled (set to 0)**: Clears all `wot_whitelist` rules. Manual `whitelist` rules are preserved. `nip42_auth_required_subscriptions` is reset to false.
4. **Admin unfollows someone**: Next kind 3 event triggers resync, the unfollowed pubkey's `wot_whitelist` rule is removed (full clear + reinsert).
5. **Kind 1059 gift wraps**: These need special handling — the relay needs to accept gift wraps addressed to it even from non-whitelisted pubkeys (for admin DMs). The `is_nip17_gift_wrap_for_relay()` check should bypass WoT.
6. **Read restriction without NIP-42 support (level 2)**: If a client doesn't support NIP-42, they cannot authenticate and therefore cannot subscribe. This is by design — it's the most effective way to reduce subscription churn from anonymous connections.
7. **NIP-11 discovery**: Clients can check NIP-11 to see if `auth_required` is true before connecting, avoiding wasted connections.
8. **Changing level from 2 to 1**: `nip42_auth_required_subscriptions` is reset to false, allowing anonymous subscriptions again. WoT whitelist rules remain for write restriction.
## Testing Strategy
1. Enable write-only WoT via DM: `wot 1`
2. Verify admin can still publish events
3. Verify followed pubkeys can publish events
4. Verify non-followed pubkeys are rejected for EVENT
5. Verify non-followed pubkeys can still subscribe (REQ)
6. Enable full WoT via DM: `wot 2`
7. Verify unauthenticated clients get AUTH challenge on REQ
8. Verify authenticated non-followed pubkeys are rejected for REQ
9. Verify authenticated followed pubkeys can subscribe
10. Verify `wot status` shows correct level and count
11. Publish new kind 3 event, verify auto-resync
12. `wot 0` — verify all pubkeys can publish and subscribe again
13. Verify admin DMs still work when WoT is enabled
## Performance Considerations
- WoT sync is O(n) where n = number of follows (typically 100-2000)
- Uses a transaction for bulk insert — fast even for large follow lists
- Auth rule check is already indexed: `idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value)`
- No additional per-event overhead — the existing `check_database_auth_rules()` already runs on every event when auth is enabled
- **Level 2 directly addresses CPU load**: With `wot_enabled=2`, unauthenticated connections cannot create subscriptions, eliminating the ~120 REQ/minute churn from anonymous connections that was causing 99% CPU
+388
View File
@@ -0,0 +1,388 @@
# Web of Trust — Admin Web UI Plan
## Overview
Add a **Web of Trust** section to the existing Authorization page in the admin web interface. The section lets the admin see whether their kind 3 contact list is on the relay, select a WoT level, trigger a sync, and view the current whitelist count.
## Architecture
### Data Flow
```mermaid
flowchart LR
A[Web UI] -->|config_set wot_enabled N| B[Kind 23456 Event]
A -->|system_command wot_sync| B
A -->|system_command wot_status| B
B --> C[Relay Backend]
C -->|Kind 23457 Response| D[Web UI updates display]
```
### How It Works
1. **On page load** — the Authorization page already calls `loadAuthRules`. We add a parallel call to `loadWotStatus` which sends a `system_command` / `wot_status` event.
2. **Backend responds** with a kind 23457 JSON containing: `wot_enabled` level, `wot_whitelist_count`, and `admin_kind3_exists` boolean.
3. **UI renders** a card showing:
- Whether the admin's kind 3 event exists on the relay
- Current WoT level as a 3-option radio/button group
- Count of whitelisted pubkeys
- A SYNC button to force resync
4. **Changing level** sends `config_set` / `wot_enabled` / `0|1|2` via the existing admin API, then triggers `wot_sync` if level > 0.
5. **SYNC button** sends `system_command` / `wot_sync`.
## Detailed Changes
### 1. Backend: New system commands in `src/config.c`
Add two new branches to `handle_system_command_unified`:
#### `wot_status` command
Returns JSON response:
```json
{
"command": "wot_status",
"status": "success",
"wot_enabled": 2,
"admin_kind3_exists": true,
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
Implementation:
- Read `wot_enabled` from config table
- Query `SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?` with admin_pubkey to check kind 3 existence
- Query `SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1` for whitelist count
- Return as kind 23457 signed response
#### `wot_sync` command
Calls `wot_sync_from_admin_kind3` and returns result:
```json
{
"command": "wot_sync",
"status": "success",
"wot_whitelist_count": 347,
"timestamp": 1234567890
}
```
### 2. Backend: Hook `config_set` for `wot_enabled`
In `handle_config_set_unified`, after the config value is updated, add a check:
```c
// After successful update, trigger WoT sync if wot_enabled changed
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
```
### 3. Frontend: HTML — WoT section in `api/index.html`
Add a new div **inside** the `authRulesSection`, before the auth rules table. This keeps WoT visually grouped with authorization:
```html
<!-- Web of Trust Section -->
<div id="wotSection" class="input-group">
<div class="section-header" style="font-size: 14px; margin-bottom: 10px;">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount">—</span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
<hr style="margin: 15px 0; border-color: var(--border-color);">
```
### 4. Frontend: CSS additions in `api/index.css`
```css
/* Web of Trust styles */
.wot-status-row, .wot-stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.wot-indicator {
padding: 2px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
.wot-indicator.wot-found { background: #28a745; color: white; }
.wot-indicator.wot-missing { background: #dc3545; color: white; }
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
.wot-level-selector { padding: 10px 0; }
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
.wot-level-btn { min-width: 100px; }
.wot-level-btn.active {
background: var(--accent-color, #007bff);
color: white;
border-color: var(--accent-color, #007bff);
}
.wot-level-description {
font-size: 12px;
color: var(--text-secondary);
margin-top: 5px;
font-style: italic;
}
```
### 5. Frontend: JavaScript functions in `api/index.js`
#### `loadWotStatus` — query current WoT state
```javascript
async function loadWotStatus() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_status"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT status query sent', 'INFO');
} catch (error) {
log('Failed to load WoT status: ' + error.message, 'ERROR');
}
}
```
#### `setWotLevel` — change WoT level via config_set
```javascript
async function setWotLevel(level) {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
// Send config_set for wot_enabled
const command_array = ["config_set", "wot_enabled", String(level)];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT level set to ' + level, 'INFO');
// Refresh status after a short delay
setTimeout(() => loadWotStatus(), 1500);
} catch (error) {
log('Failed to set WoT level: ' + error.message, 'ERROR');
}
}
```
#### `syncWot` — force WoT sync
```javascript
async function syncWot() {
try {
if (!isLoggedIn || !userPubkey || !relayPool) return;
const command_array = ["system_command", "wot_sync"];
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) return;
const event = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
const signedEvent = await window.nostr.signEvent(event);
const url = relayConnectionUrl.value.trim();
await relayPool.publish([url], signedEvent);
log('WoT sync command sent', 'INFO');
// Refresh status after sync completes
setTimeout(() => loadWotStatus(), 2000);
} catch (error) {
log('Failed to sync WoT: ' + error.message, 'ERROR');
}
}
```
#### `handleWotStatusResponse` — process backend response
```javascript
function handleWotStatusResponse(responseData) {
const kind3Indicator = document.getElementById('wotKind3Indicator');
const whitelistCount = document.getElementById('wotWhitelistCount');
const levelDesc = document.getElementById('wotLevelDescription');
// Update kind 3 indicator
if (kind3Indicator) {
if (responseData.admin_kind3_exists) {
kind3Indicator.textContent = 'Found ✓';
kind3Indicator.className = 'wot-indicator wot-found';
} else {
kind3Indicator.textContent = 'Not Found ✗';
kind3Indicator.className = 'wot-indicator wot-missing';
}
}
// Update whitelist count
if (whitelistCount) {
whitelistCount.textContent = responseData.wot_whitelist_count || 0;
}
// Update level buttons
const level = responseData.wot_enabled || 0;
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('active', i === level);
}
});
// Update description
const descriptions = [
'Level 0: Open relay — anyone can read and write',
'Level 1: Write-only — only followed pubkeys can publish events',
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
];
if (levelDesc) {
levelDesc.textContent = descriptions[level] || descriptions[0];
}
// Enable/disable sync button based on kind 3 existence
const syncBtn = document.getElementById('wotSyncBtn');
if (syncBtn) {
syncBtn.disabled = !responseData.admin_kind3_exists;
}
}
```
#### Wire into existing response handler
In the existing `handleSystemCommandResponse` function, add:
```javascript
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
handleWotStatusResponse(responseData);
}
```
#### Wire into page navigation
In the `authorization` case of the page switch handler, add:
```javascript
case 'authorization':
loadAuthRules().catch(...);
loadWotStatus().catch(error => {
console.log('Auto-load WoT status failed: ' + error.message);
});
break;
```
## Files to Modify
| File | Changes |
|------|---------|
| `src/config.c` | Add `wot_status` and `wot_sync` branches to `handle_system_command_unified`; add WoT sync hook to `handle_config_set_unified` |
| `api/index.html` | Add WoT section HTML inside `authRulesSection` |
| `api/index.css` | Add WoT-specific CSS styles |
| `api/index.js` | Add `loadWotStatus`, `setWotLevel`, `syncWot`, `handleWotStatusResponse` functions; wire into page nav and response handler |
## Edge Cases
1. **Admin not logged in** — WoT section shows but buttons are disabled; status shows "Checking..."
2. **No kind 3 event** — Indicator shows red "Not Found ✗"; SYNC button is disabled; level selector still works but sync will whitelist only admin + relay
3. **Level change from 2 to 0** — Backend clears all wot_whitelist rules and resets nip42_auth_required_subscriptions
4. **Concurrent config_set and wot_sync** — The backend handles these sequentially via SQLite transactions; no race condition
## UI Mockup
```
┌─────────────────────────────────────────────┐
│ WEB OF TRUST │
│ │
│ Admin Contact List (kind 3): [Found ✓] │
│ │
│ WoT Level: │
│ [ OFF ] [ WRITE ONLY ] [ FULL ] │
│ Level 2: Full — only followed pubkeys can │
│ publish AND subscribe (NIP-42 required) │
│ │
│ Whitelisted Pubkeys: 347 │
│ │
│ [ SYNC FROM KIND 3 ] [ REFRESH STATUS ] │
├─────────────────────────────────────────────┤
│ ─────────────────────────────────────────── │
│ AUTH RULES MANAGEMENT │
│ ... existing auth rules table ... │
└─────────────────────────────────────────────┘
```
+1 -1
View File
@@ -1 +1 @@
2324020
1946657
+424 -4
View File
@@ -956,7 +956,8 @@ static int validate_config_field(const char* key, const char* value, char* error
if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0) {
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
if (!is_valid_boolean(value)) {
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
return -1;
@@ -1724,7 +1725,8 @@ int populate_default_config_values(void) {
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -2129,6 +2131,296 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
return (rc == SQLITE_DONE) ? 0 : -1;
}
// ================================
// WEB OF TRUST (WoT) SYNC FUNCTION
// ================================
/**
* Sync Web of Trust whitelist from admin's kind 3 (contact list) event
* Extracts p tags from the admin's most recent kind 3 event and creates
* wot_whitelist auth rules for each followed pubkey.
*
* @return 0 on success, -1 on error
*/
int wot_sync_from_admin_kind3(void) {
if (!g_db) {
DEBUG_ERROR("WoT sync: database not available");
return -1;
}
// Check if WoT is enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level <= 0) {
DEBUG_INFO("WoT sync: wot_enabled is %d, skipping sync", wot_level);
return 0; // WoT disabled, nothing to do
}
// Get admin pubkey from config
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey) {
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
return -1;
}
// Get relay pubkey from config
const char* relay_pubkey = get_config_value("relay_pubkey");
if (!relay_pubkey) {
free((char*)admin_pubkey);
DEBUG_ERROR("WoT sync: relay_pubkey not configured");
return -1;
}
DEBUG_INFO("WoT sync: Starting sync from admin's kind 3 event (level %d)", wot_level);
sqlite3_stmt* stmt = NULL;
int rc;
int whitelisted_count = 0;
// Step 1: Query p tags from admin's most recent kind 3 event
const char* query_sql =
"SELECT DISTINCT et.tag_value FROM event_tags et "
"JOIN events e ON et.event_id = e.id "
"WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p' "
"AND e.created_at = (SELECT MAX(created_at) FROM events WHERE kind = 3 AND pubkey = ?)";
rc = sqlite3_prepare_v2(g_db, query_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare p-tag query: %s", sqlite3_errmsg(g_db));
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, admin_pubkey, -1, SQLITE_STATIC);
// Collect all p tags
char** p_tags = NULL;
int p_tag_count = 0;
int p_tag_capacity = 16;
p_tags = malloc(p_tag_capacity * sizeof(char*));
if (!p_tags) {
DEBUG_ERROR("WoT sync: Failed to allocate p_tag array");
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char* p_tag = (const char*)sqlite3_column_text(stmt, 0);
if (p_tag) {
// Expand array if needed
if (p_tag_count >= p_tag_capacity) {
p_tag_capacity *= 2;
char** new_tags = realloc(p_tags, p_tag_capacity * sizeof(char*));
if (!new_tags) {
DEBUG_ERROR("WoT sync: Failed to expand p_tag array");
// Free existing tags
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
sqlite3_finalize(stmt);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
p_tags = new_tags;
}
p_tags[p_tag_count] = strdup(p_tag);
p_tag_count++;
}
}
sqlite3_finalize(stmt);
// If no kind 3 event found, just log and continue (admin and relay will still be whitelisted)
if (p_tag_count == 0) {
DEBUG_WARN("WoT sync: No p tags found in admin's kind 3 event");
} else {
DEBUG_INFO("WoT sync: Found %d followed pubkeys in kind 3 event", p_tag_count);
}
// Step 2: Begin transaction
rc = sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to begin transaction: %s", sqlite3_errmsg(g_db));
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 3: Clear existing wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
rc = sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to clear existing wot_whitelist rules: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
DEBUG_TRACE("WoT sync: Cleared existing wot_whitelist rules");
// Step 4: Insert admin pubkey as wot_whitelist (always allowed)
const char* insert_sql =
"INSERT INTO auth_rules (rule_type, pattern_type, pattern_value, active) "
"VALUES ('wot_whitelist', 'pubkey', ?, 1)";
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert admin pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted admin pubkey: %.16s...", admin_pubkey);
// Step 5: Insert relay pubkey as wot_whitelist (needed for DM responses)
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert statement for relay: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, relay_pubkey, -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert relay pubkey whitelist: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
DEBUG_TRACE("WoT sync: Whitelisted relay pubkey: %.16s...", relay_pubkey);
// Step 6: Insert each p tag from kind 3 event
for (int i = 0; i < p_tag_count; i++) {
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to prepare insert for p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
sqlite3_bind_text(stmt, 1, p_tags[i], -1, SQLITE_STATIC);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("WoT sync: Failed to insert p_tag %d: %s", i, sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
// Free remaining tags
for (int j = i; j < p_tag_count; j++) {
free(p_tags[j]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
whitelisted_count++;
}
// Step 7: Commit transaction
rc = sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("WoT sync: Failed to commit transaction: %s", sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
return -1;
}
// Step 8: Enable auth_enabled and optionally nip42_auth_required_subscriptions
if (update_config_in_table("auth_enabled", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set auth_enabled");
} else {
DEBUG_INFO("WoT sync: Enabled auth_enabled");
}
if (wot_level == 2) {
if (update_config_in_table("nip42_auth_required_subscriptions", "true") != 0) {
DEBUG_WARN("WoT sync: Failed to set nip42_auth_required_subscriptions");
} else {
DEBUG_INFO("WoT sync: Enabled nip42_auth_required_subscriptions for level 2");
}
} else {
// Level 1: ensure nip42_auth_required_subscriptions is false
if (update_config_in_table("nip42_auth_required_subscriptions", "false") != 0) {
DEBUG_WARN("WoT sync: Failed to reset nip42_auth_required_subscriptions");
}
}
// Cleanup
for (int i = 0; i < p_tag_count; i++) {
free(p_tags[i]);
}
free(p_tags);
free((char*)admin_pubkey);
free((char*)relay_pubkey);
DEBUG_INFO("WoT sync: Complete - whitelisted %d pubkeys (admin + relay + %d follows)",
whitelisted_count, p_tag_count);
return 0;
}
// ================================
// UNIFIED TAG PARSING UTILITIES
// ================================
@@ -3025,6 +3317,20 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
return -1;
}
// Special handling for wot_enabled changes - trigger sync or cleanup
if (strcmp(config_key, "wot_enabled") == 0) {
int new_level = atoi(config_value);
if (new_level > 0) {
DEBUG_INFO("Config set: wot_enabled changed to %d, triggering WoT sync", new_level);
wot_sync_from_admin_kind3();
} else {
// Level 0: clear wot_whitelist rules and reset auth flags
DEBUG_INFO("Config set: wot_enabled changed to 0, clearing wot_whitelist rules");
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
}
}
// Build response
cJSON* response = cJSON_CreateObject();
@@ -3292,6 +3598,118 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
snprintf(error_message, error_size, "failed to send restart acknowledgment");
return -1;
}
else if (strcmp(command, "wot_status") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT status");
return -1;
}
// Get current WoT level
int wot_enabled = get_config_int("wot_enabled", 0);
// Check if admin's kind 3 event exists
int admin_kind3_exists = 0;
const char* kind3_sql = "SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?";
sqlite3_stmt* kind3_stmt;
int rc = sqlite3_prepare_v2(g_db, kind3_sql, -1, &kind3_stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(kind3_stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
if (sqlite3_step(kind3_stmt) == SQLITE_ROW) {
admin_kind3_exists = sqlite3_column_int(kind3_stmt, 0) > 0;
}
sqlite3_finalize(kind3_stmt);
}
// Count WoT whitelist entries
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_status");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddBoolToObject(response, "admin_kind3_exists", admin_kind3_exists);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return 0;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT status response");
return -1;
}
else if (strcmp(command, "wot_sync") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
return -1;
}
// Check if WoT is enabled
int wot_enabled = get_config_int("wot_enabled", 0);
if (wot_enabled <= 0) {
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
return -1;
}
// Trigger WoT sync
int sync_result = wot_sync_from_admin_kind3();
// Get updated whitelist count
int wot_whitelist_count = 0;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
sqlite3_stmt* count_stmt;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
}
sqlite3_finalize(count_stmt);
}
// Build response
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "wot_sync");
cJSON_AddStringToObject(response, "status", sync_result == 0 ? "success" : "error");
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
if (sync_result != 0) {
cJSON_AddStringToObject(response, "message", "WoT sync failed");
}
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
return sync_result;
}
cJSON_Delete(response);
snprintf(error_message, error_size, "failed to send WoT sync response");
return -1;
}
else {
snprintf(error_message, error_size, "invalid: unknown system command '%s'", command);
return -1;
@@ -4089,7 +4507,8 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required_events") == 0 ||
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
strcmp(key, "nip70_protected_events_enabled") == 0) {
strcmp(key, "nip70_protected_events_enabled") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
@@ -4374,7 +4793,8 @@ int populate_config_table_from_event(const cJSON* event) {
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0) {
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
}
+4 -1
View File
@@ -116,7 +116,10 @@ cJSON* build_query_response(const char* query_type, cJSON* results_array, int to
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
const char* pattern_value);
const char* pattern_value);
// Web of Trust (WoT) sync function
int wot_sync_from_admin_kind3(void);
// Unified configuration cache management
void force_config_cache_refresh(void);
+10 -1
View File
@@ -87,7 +87,16 @@ static const struct {
{"nip59_timestamp_max_delay_sec", "0"},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "1"}
{"kind_1_status_posts_hours", "1"},
// Web of Trust Settings
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
{"wot_enabled", "0"},
// NIP-17 Admin DM Settings
// When false (default), Kind 1059 gift wrap events are stored normally without expensive decryption attempts.
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
{"nip17_admin_enabled", "false"}
};
// Number of default configuration values
+152
View File
@@ -37,10 +37,13 @@ extern const char* get_tag_value(cJSON* event, const char* tag_name, int value_i
extern char* get_relay_private_key(void);
extern const char* get_config_value(const char* key);
extern int get_config_bool(const char* key, int default_value);
extern int get_config_int(const char* key, int default_value);
extern int update_config_in_table(const char* key, const char* value);
// Forward declarations for database functions
extern int store_event(cJSON* event);
extern int broadcast_event_to_subscriptions(cJSON* event);
extern int store_event_tags(const char* event_id, cJSON* tags);
// Forward declarations for stats generation (moved to api.c)
extern char* generate_stats_json(void);
@@ -598,6 +601,155 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
return 0;
}
// Check for WoT (Web of Trust) commands
else if (strstr(content_lower, "wot") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing WoT command");
// Skip "wot" prefix and find the subcommand
char* wot_cmd = strstr(content_lower, "wot");
if (wot_cmd) {
wot_cmd += 3; // Skip "wot"
while (*wot_cmd == ' ') wot_cmd++; // Skip spaces
char response_msg[1024];
int wot_level = get_config_int("wot_enabled", 0);
extern int wot_sync_from_admin_kind3(void);
if (strcmp(wot_cmd, "0") == 0 || strcmp(wot_cmd, "off") == 0) {
// Disable WoT
update_config_in_table("wot_enabled", "0");
update_config_in_table("auth_enabled", "false");
update_config_in_table("nip42_auth_required_subscriptions", "false");
// Clear wot_whitelist rules
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
snprintf(response_msg, sizeof(response_msg),
"🔓 Web of Trust Disabled\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT has been turned off.\n"
"\n"
"The relay is now open to all pubkeys for reading and writing.\n"
"Manual whitelist/blacklist rules are preserved.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT disabled");
}
else if (strcmp(wot_cmd, "1") == 0 || strcmp(wot_cmd, "write") == 0) {
// Write-only mode
update_config_in_table("wot_enabled", "1");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"✍️ Web of Trust: Write-Only Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 1 (Write-only restriction)\n"
"\n"
"Only pubkeys you follow can publish events.\n"
"Anyone can still subscribe and read events.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to write-only mode");
}
else if (strcmp(wot_cmd, "2") == 0 || strcmp(wot_cmd, "full") == 0) {
// Full mode (write + read restriction)
update_config_in_table("wot_enabled", "2");
wot_sync_from_admin_kind3();
wot_level = get_config_int("wot_enabled", 0);
snprintf(response_msg, sizeof(response_msg),
"🔒 Web of Trust: Full Mode\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: 2 (Full restriction)\n"
"\n"
"Only pubkeys you follow can:\n"
" • Publish events\n"
" • Subscribe to events (requires NIP-42 auth)\n"
"\n"
"This eliminates anonymous subscription churn.\n"
"\n"
"The relay will now sync from your kind 3 (contact list) event.");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT set to full mode");
}
else if (strcmp(wot_cmd, "sync") == 0) {
// Force resync
wot_sync_from_admin_kind3();
snprintf(response_msg, sizeof(response_msg),
"🔄 Web of Trust: Sync Complete\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"WoT whitelist has been refreshed from your\n"
"kind 3 (contact list) event.\n"
"\n"
"Current level: %d", wot_level);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT manual sync completed");
}
else if (strcmp(wot_cmd, "status") == 0 || strlen(wot_cmd) == 0) {
// Show status
// Count whitelisted pubkeys
sqlite3_stmt* stmt;
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
int whitelist_count = 0;
if (sqlite3_prepare_v2(g_db, count_sql, -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
whitelist_count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
const char* level_str;
if (wot_level == 0) level_str = "Off (open relay)";
else if (wot_level == 1) level_str = "Write-only (followed pubkeys can publish)";
else if (wot_level == 2) level_str = "Full (followed pubkeys can publish AND subscribe)";
else level_str = "Unknown";
snprintf(response_msg, sizeof(response_msg),
"📊 Web of Trust Status\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Level: %d\n"
"%s\n"
"\n"
"Whitelisted pubkeys: %d\n"
"\n"
"Commands:\n"
" wot 0/off - Disable WoT\n"
" wot 1/write - Write-only mode\n"
" wot 2/full - Full restriction mode\n"
" wot sync - Force resync from kind 3\n"
" wot status - Show this status",
wot_level, level_str, whitelist_count);
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
DEBUG_INFO("DM_ADMIN: WoT status displayed");
}
else {
// Unknown wot subcommand
snprintf(response_msg, sizeof(response_msg),
"❌ Unknown WoT Command\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"\n"
"Usage: wot [command]\n"
"\n"
"Commands:\n"
" 0, off - Disable WoT\n"
" 1, write - Write-only mode\n"
" 2, full - Full restriction mode\n"
" sync - Force resync from kind 3\n"
" status - Show current status");
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
}
}
return 0;
}
else {
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
// Check if it's a confirmation response (yes/no)
File diff suppressed because one or more lines are too long
+126 -6
View File
@@ -657,6 +657,46 @@ const char* extract_d_tag_value(cJSON* tags) {
return NULL;
}
// Insert denormalized tags into event_tags table for fast indexed lookups
int store_event_tags(const char* event_id, cJSON* tags) {
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
return 0; // Not an error if no tags
}
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
return -1;
}
int tag_index = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
cJSON* value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(name) && cJSON_IsString(value)) {
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, tag_index);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
}
}
}
tag_index++;
}
sqlite3_finalize(stmt);
return 0;
}
// Store event in database
int store_event(cJSON* event) {
@@ -738,7 +778,9 @@ int store_event(cJSON* event) {
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
if (rc != SQLITE_CONSTRAINT) {
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
}
sqlite3_finalize(stmt);
@@ -783,6 +825,71 @@ int store_event(cJSON* event) {
// Call monitoring hook after successful event storage
monitoring_on_event_stored();
// After successful event storage, insert denormalized tags
store_event_tags(cJSON_GetStringValue(id), tags);
// Check if this is a kind 3 event from the admin — trigger WoT sync
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
if (admin_pubkey) free((char*)admin_pubkey);
}
}
return 0;
}
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
// Check if event_tags is already populated
sqlite3_stmt* check_stmt;
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
sqlite3_finalize(check_stmt);
DEBUG_INFO("event_tags already populated, skipping");
return 0;
}
sqlite3_finalize(check_stmt);
DEBUG_INFO("Populating event_tags from existing events...");
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
// Use a transaction for bulk insert performance
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
int event_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
if (event_id && tags_json) {
cJSON* tags = cJSON_Parse(tags_json);
if (tags) {
store_event_tags(event_id, tags);
cJSON_Delete(tags);
event_count++;
}
}
}
sqlite3_finalize(stmt);
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
DEBUG_INFO("Populated event_tags for %d events", event_count);
return 0;
}
@@ -1240,8 +1347,8 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
if (tag_value_count > 0) {
// Use EXISTS with parameterized query
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = ? AND json_extract(value, '$[1]') IN (");
// Use indexed event_tags table instead of json_each()
snprintf(sql_ptr, remaining, " AND id IN (SELECT event_id FROM event_tags WHERE tag_name = ? AND tag_value IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
@@ -1373,6 +1480,10 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
sqlite3_bind_text(stmt, i + 1, bind_params[i], -1, SQLITE_TRANSIENT);
}
// Cache config values outside the row loop (performance fix)
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
int row_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
row_count++;
@@ -1397,9 +1508,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Check expiration filtering (NIP-40) at application level
int expiration_enabled = get_config_bool("expiration_enabled", 1);
int filter_responses = get_config_bool("expiration_filter", 1);
// (expiration_enabled and filter_responses are cached outside the loop)
if (expiration_enabled && filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
@@ -1989,6 +2098,17 @@ int main(int argc, char* argv[]) {
// Initialize kind-based index for fast subscription lookup
init_kind_index();
// Populate event_tags from existing events (for tag-based lookups)
populate_event_tags_from_existing();
// Sync Web of Trust whitelist if enabled
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
DEBUG_INFO("WoT enabled (level %d) - syncing from admin's kind 3 event", wot_level);
extern int wot_sync_from_admin_kind3(void);
wot_sync_from_admin_kind3();
}
// Cleanup orphaned subscriptions from previous runs
cleanup_all_subscriptions_on_startup();
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 1
#define CRELAY_VERSION_MINOR 2
#define CRELAY_VERSION_PATCH 4
#define CRELAY_VERSION "v1.2.4"
#define CRELAY_VERSION_PATCH 5
#define CRELAY_VERSION "v1.2.5"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+5 -5
View File
@@ -595,10 +595,10 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
}
}
// Step 3: Check pubkey whitelist
// Step 3: Check pubkey whitelist (includes wot_whitelist)
const char *whitelist_sql =
"SELECT rule_type FROM auth_rules WHERE rule_type = "
"'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
"SELECT rule_type FROM auth_rules WHERE rule_type IN "
"('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
@@ -611,9 +611,9 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
sqlite3_finalize(stmt);
}
// Step 4: Check if any whitelist rules exist - if yes, deny by default
// Step 4: Check if any whitelist rules exist - if yes, deny by default (includes wot_whitelist)
const char *whitelist_exists_sql =
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' "
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
rc = sqlite3_prepare_v2(db, whitelist_exists_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
+25 -6
View File
@@ -1,11 +1,11 @@
/* Embedded SQL Schema for C Nostr Relay
* Schema Version: 11
* Schema Version: 12
*/
#ifndef SQL_SCHEMA_H
#define SQL_SCHEMA_H
/* Schema version constant */
#define EMBEDDED_SCHEMA_VERSION "11"
#define EMBEDDED_SCHEMA_VERSION "12"
/* Embedded SQL schema as C string literal */
static const char* const EMBEDDED_SCHEMA_SQL =
@@ -14,7 +14,7 @@ static const char* const EMBEDDED_SCHEMA_SQL =
-- Configuration system using config table\n\
\n\
-- Schema version tracking\n\
PRAGMA user_version = 11;\n\
PRAGMA user_version = 12;\n\
\n\
-- Enable foreign key support\n\
PRAGMA foreign_keys = ON;\n\
@@ -49,6 +49,25 @@ CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);\n\
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n\
CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);\n\
\n\
-- Denormalized event tags for fast indexed lookups\n\
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row\n\
CREATE TABLE event_tags (\n\
event_id TEXT NOT NULL,\n\
tag_name TEXT NOT NULL,\n\
tag_value TEXT NOT NULL,\n\
tag_index INTEGER NOT NULL DEFAULT 0,\n\
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE\n\
);\n\
\n\
-- Primary lookup index: find events by tag name + value\n\
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);\n\
\n\
-- Reverse lookup: find all tags for an event (for cleanup)\n\
CREATE INDEX idx_event_tags_event ON event_tags(event_id);\n\
\n\
-- Composite index for common query pattern: tag + kind (via join)\n\
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);\n\
\n\
-- Schema information table\n\
CREATE TABLE schema_info (\n\
key TEXT PRIMARY KEY,\n\
@@ -58,8 +77,8 @@ CREATE TABLE schema_info (\n\
\n\
-- Insert schema metadata\n\
INSERT INTO schema_info (key, value) VALUES\n\
('version', '11'),\n\
('description', 'Added event_json column for 2500x performance improvement in event retrieval'),\n\
('version', '12'),\n\
('description', 'Added event_tags table for fast indexed tag lookups, replacing json_each() correlated subqueries'),\n\
('created_at', strftime('%s', 'now'));\n\
\n\
-- Helper views for common queries\n\
@@ -129,7 +148,7 @@ CREATE TABLE relay_seckey (\n\
-- Used by request_validator.c for unified validation\n\
CREATE TABLE auth_rules (\n\
id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')),\n\
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist')),\n\
pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),\n\
pattern_value TEXT,\n\
active INTEGER NOT NULL DEFAULT 1,\n\
+90 -48
View File
@@ -768,44 +768,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
// NIP-17 gift wrap events
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
// to prevent expensive decryption on every incoming gift wrap event
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
if (nip17_admin_enabled) {
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
// NIP-17 admin DMs disabled: store gift wrap as a regular event
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 14) {
@@ -914,6 +928,20 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// WoT read restriction check (wot_enabled == 2)
// After NIP-42 auth, check if authenticated pubkey is in WoT whitelist
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
// Client is authenticated - check if their pubkey is in the WoT whitelist
extern int check_database_auth_rules(const char* pubkey, const char* operation, const char* resource_hash);
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
if (wot_result != 0) {
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
cJSON_Delete(json);
return 0;
}
}
DEBUG_TRACE("REQ message passed authentication check");
// Handle REQ message
@@ -1476,44 +1504,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
// NIP-17 gift wrap events
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
// to prevent expensive decryption on every incoming gift wrap event
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
if (nip17_admin_enabled) {
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
// NIP-17 admin DMs disabled: store gift wrap as a regular event
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 14) {
+22 -7
View File
@@ -329,12 +329,12 @@ run_count_test() {
fi
# Test 5: Count events with specific tags
# NOTE: Tag filtering is currently not working in the relay - should return the tagged events
local expected_type_regular=$((0 + BASELINE_TYPE_REGULAR)) # Currently returns 0 due to tag filtering bug
# Tag filtering now works with event_tags table
local expected_type_regular=$((3 + BASELINE_TYPE_REGULAR)) # 3 new regular events have type=regular tag
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "$expected_type_regular"; then
((test_failures++))
fi
local expected_test_nip45=$((0 + BASELINE_TEST_NIP45)) # Currently returns 0 due to tag filtering bug
local expected_test_nip45=$((3 + BASELINE_TEST_NIP45)) # 3 new regular events have test=nip45 tag
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "$expected_test_nip45"; then
((test_failures++))
fi
@@ -362,8 +362,8 @@ run_count_test() {
fi
# Test 9: Count with multiple filters combined
# NOTE: Combined tag filtering is currently not working in the relay
local expected_combined=$((0 + BASELINE_COMBINED)) # Currently returns 0 due to tag filtering bug
# Combined tag+kind filtering now works with event_tags table
local expected_combined=$((3 + BASELINE_COMBINED)) # 3 new regular events match all criteria
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "$expected_combined"; then
((test_failures++))
fi
@@ -379,8 +379,23 @@ run_count_test() {
((test_failures++))
fi
# Test 12: Count non-existent kind
if ! test_count "count_nonexistent" '{"kinds":[99999]}' "Count non-existent kind" "0"; then
# Test 12: Count non-existent kind (kind 99999 is NDK ping, rejected with NOTICE)
# The relay correctly rejects invalid kinds with a NOTICE instead of COUNT
print_step "Testing COUNT: Count non-existent kind (kind 99999 - NDK ping)"
local count_message='["COUNT","count_nonexistent",{"kinds":[99999]}]'
local response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
if echo "$response" | grep -q '"NOTICE"'; then
print_success "Count non-existent kind - Correctly rejected with NOTICE"
elif echo "$response" | grep -q '"COUNT"'; then
local actual_count=$(echo "$response" | jq -r '.[2].count' 2>/dev/null)
if [[ "$actual_count" == "0" ]]; then
print_success "Count non-existent kind - Expected: 0, Got: 0"
else
print_error "Count non-existent kind - Expected: 0, Got: $actual_count"
((test_failures++))
fi
else
print_error "Count non-existent kind - No response from relay"
((test_failures++))
fi