Browse Source

Double-checked relay implementation and added status page and better logging

master
Silberengel 8 months ago
parent
commit
b5e854a573
  1. 162
      src/lib/components/RelayStatus.svelte
  2. 8
      src/lib/consts.ts
  3. 318
      src/lib/ndk.ts
  4. 12
      src/lib/utils/nostrUtils.ts
  5. 5
      src/routes/about/+page.svelte

162
src/lib/components/RelayStatus.svelte

@ -0,0 +1,162 @@
<script lang="ts">
import { Button, Alert } from 'flowbite-svelte';
import { ndkInstance, ndkSignedIn, testRelayConnection, checkWebSocketSupport, checkEnvironmentForWebSocketDowngrade } from '$lib/ndk';
import { standardRelays, anonymousRelays } from '$lib/consts';
import { onMount } from 'svelte';
import { feedType } from '$lib/stores';
import { inboxRelays, outboxRelays } from '$lib/ndk';
import { FeedType } from '$lib/consts';
interface RelayStatus {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
testing: boolean;
}
let relayStatuses = $state<RelayStatus[]>([]);
let testing = $state(false);
async function runRelayTests() {
testing = true;
const ndk = $ndkInstance;
if (!ndk) {
testing = false;
return;
}
let relaysToTest: string[] = [];
if ($feedType === FeedType.UserRelays && $ndkSignedIn) {
// Use user's relays (inbox + outbox), deduplicated
const userRelays = new Set([
...$inboxRelays,
...$outboxRelays
]);
relaysToTest = Array.from(userRelays);
} else {
// Use default relays (standard + anonymous), deduplicated
relaysToTest = Array.from(new Set([
...standardRelays,
...anonymousRelays
]));
}
console.log('[RelayStatus] Relays to test:', relaysToTest);
relayStatuses = relaysToTest.map(url => ({
url,
connected: false,
requiresAuth: false,
testing: true
}));
const results = await Promise.allSettled(
relaysToTest.map(async (url) => {
console.log('[RelayStatus] Testing relay:', url);
try {
return await testRelayConnection(url, ndk);
} catch (error) {
return {
connected: false,
requiresAuth: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
})
);
relayStatuses = relayStatuses.map((status, index) => {
const result = results[index];
if (result.status === 'fulfilled') {
return {
...status,
...result.value,
testing: false
};
} else {
return {
...status,
connected: false,
requiresAuth: false,
error: 'Test failed',
testing: false
};
}
});
testing = false;
}
$effect(() => {
// Re-run relay tests when feed type, login state, or relay lists change
void runRelayTests();
});
onMount(() => {
checkWebSocketSupport();
checkEnvironmentForWebSocketDowngrade();
});
function getStatusColor(status: RelayStatus): string {
if (status.testing) return 'text-yellow-600';
if (status.connected) return 'text-green-600';
if (status.requiresAuth && !$ndkSignedIn) return 'text-orange-600';
return 'text-red-600';
}
function getStatusText(status: RelayStatus): string {
if (status.testing) return 'Testing...';
if (status.connected) return 'Connected';
if (status.requiresAuth && !$ndkSignedIn) return 'Requires Authentication';
if (status.error) return `Error: ${status.error}`;
return 'Failed to Connect';
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Relay Connection Status</h3>
<Button
size="sm"
onclick={runRelayTests}
disabled={testing}
>
{testing ? 'Testing...' : 'Refresh'}
</Button>
</div>
{#if !$ndkSignedIn}
<Alert color="yellow">
<span class="font-medium">Anonymous Mode</span>
<p class="mt-1 text-sm">
You are not signed in. Some relays require authentication and may not be accessible.
Sign in to access all relays.
</p>
</Alert>
{/if}
<div class="space-y-2">
{#each relayStatuses as status}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex-1">
<div class="font-medium">{status.url}</div>
<div class="text-sm {getStatusColor(status)}">
{getStatusText(status)}
</div>
</div>
<div class="w-3 h-3 rounded-full {getStatusColor(status).replace('text-', 'bg-')}"></div>
</div>
{/each}
</div>
{#if relayStatuses.some(s => s.requiresAuth && !$ndkSignedIn)}
<Alert color="orange">
<span class="font-medium">Authentication Required</span>
<p class="mt-1 text-sm">
Some relays require authentication. Sign in to access these relays.
</p>
</Alert>
{/if}
</div>

8
src/lib/consts.ts

@ -10,6 +10,14 @@ export const standardRelays = [
//'wss://thecitadel.gitcitadel.eu', //'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu', //'wss://theforest.gitcitadel.eu',
]; ];
// Non-auth relays for anonymous users
export const anonymousRelays = [
'wss://thecitadel.nostr1.com',
'wss://theforest.nostr1.com',
'wss://profiles.nostr1.com',
'wss://freelay.sovbit.host',
];
export const fallbackRelays = [ export const fallbackRelays = [
'wss://purplepag.es', 'wss://purplepag.es',
'wss://indexer.coracle.social', 'wss://indexer.coracle.social',

318
src/lib/ndk.ts

@ -1,6 +1,6 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser, NDKEvent } from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts'; import { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts';
import { feedType } from './stores'; import { feedType } from './stores';
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
@ -12,6 +12,237 @@ export const activePubkey: Writable<string | null> = writable(null);
export const inboxRelays: Writable<string[]> = writable([]); export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]); export const outboxRelays: Writable<string[]> = writable([]);
/**
* Custom authentication policy that handles NIP-42 authentication manually
* when the default NDK authentication fails
*/
class CustomRelayAuthPolicy {
private ndk: NDK;
private challenges: Map<string, string> = new Map();
constructor(ndk: NDK) {
this.ndk = ndk;
}
/**
* Handles authentication for a relay
* @param relay The relay to authenticate with
* @returns Promise that resolves when authentication is complete
*/
async authenticate(relay: NDKRelay): Promise<void> {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn('[NDK.ts] No signer or active user available for relay authentication');
return;
}
try {
console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`);
// Listen for AUTH challenges
relay.on('auth', (challenge: string) => {
console.debug(`[NDK.ts] Received AUTH challenge from ${relay.url}:`, challenge);
this.challenges.set(relay.url, challenge);
this.handleAuthChallenge(relay, challenge);
});
// Listen for auth-required errors (handle via notice events)
relay.on('notice', (message: string) => {
if (message.includes('auth-required')) {
console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message);
this.handleAuthRequired(relay, message);
}
});
// Listen for successful authentication
relay.on('authed', () => {
console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`);
});
// Listen for authentication failures
relay.on('auth:failed', (error: any) => {
console.error(`[NDK.ts] Authentication failed for ${relay.url}:`, error);
});
} catch (error) {
console.error(`[NDK.ts] Error setting up authentication for ${relay.url}:`, error);
}
}
/**
* Handles AUTH challenge from relay
*/
private async handleAuthChallenge(relay: NDKRelay, challenge: string): Promise<void> {
try {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn('[NDK.ts] No signer available for AUTH challenge');
return;
}
// Create NIP-42 authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: '',
pubkey: this.ndk.activeUser.pubkey
};
// Create and sign the authentication event using NDKEvent
const authNDKEvent = new NDKEvent(this.ndk, authEvent);
await authNDKEvent.sign();
// Send AUTH message to relay using the relay's publish method
await relay.publish(authNDKEvent);
console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`);
} catch (error) {
console.error(`[NDK.ts] Error handling AUTH challenge for ${relay.url}:`, error);
}
}
/**
* Handles auth-required error from relay
*/
private async handleAuthRequired(relay: NDKRelay, message: string): Promise<void> {
const challenge = this.challenges.get(relay.url);
if (challenge) {
await this.handleAuthChallenge(relay, challenge);
} else {
console.warn(`[NDK.ts] Auth required from ${relay.url} but no challenge available`);
}
}
}
/**
* Checks if the current environment might cause WebSocket protocol downgrade
*/
export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug('[NDK.ts] Environment Check for WebSocket Protocol:');
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
const isHttp = window.location.protocol === 'http:';
const isHttps = window.location.protocol === 'https:';
console.debug('[NDK.ts] - Is localhost:', isLocalhost);
console.debug('[NDK.ts] - Protocol:', window.location.protocol);
console.debug('[NDK.ts] - Is HTTP:', isHttp);
console.debug('[NDK.ts] - Is HTTPS:', isHttps);
if (isLocalhost && isHttp) {
console.warn('[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected');
console.warn('[NDK.ts] This is normal for development environments');
} else if (isHttp) {
console.error('[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure');
console.error('[NDK.ts] Consider using HTTPS in production');
} else if (isHttps) {
console.debug('[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work');
}
}
/**
* Checks WebSocket protocol support and logs diagnostic information
*/
export function checkWebSocketSupport(): void {
console.debug('[NDK.ts] WebSocket Support Diagnostics:');
console.debug('[NDK.ts] - Protocol:', window.location.protocol);
console.debug('[NDK.ts] - Hostname:', window.location.hostname);
console.debug('[NDK.ts] - Port:', window.location.port);
console.debug('[NDK.ts] - User Agent:', navigator.userAgent);
// Test if secure WebSocket is supported
try {
const testWs = new WebSocket('wss://echo.websocket.org');
testWs.onopen = () => {
console.debug('[NDK.ts] ✓ Secure WebSocket (wss://) is supported');
testWs.close();
};
testWs.onerror = () => {
console.warn('[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported');
};
} catch (error) {
console.warn('[NDK.ts] ✗ WebSocket test failed:', error);
}
}
/**
* Tests connection to a relay and returns connection status
* @param relayUrl The relay URL to test
* @param ndk The NDK instance
* @returns Promise that resolves to connection status
*/
export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
const relay = new NDKRelay(secureUrl, undefined, new NDK());
let authRequired = false;
let connected = false;
let error: string | undefined;
let actualUrl: string | undefined;
const timeout = setTimeout(() => {
relay.disconnect();
resolve({
connected: false,
requiresAuth: authRequired,
error: 'Connection timeout',
actualUrl
});
}, 5000);
relay.on('connect', () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`);
connected = true;
actualUrl = secureUrl;
clearTimeout(timeout);
relay.disconnect();
resolve({
connected: true,
requiresAuth: authRequired,
error,
actualUrl
});
});
relay.on('notice', (message: string) => {
if (message.includes('auth-required')) {
authRequired = true;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
}
});
relay.on('disconnect', () => {
if (!connected) {
error = 'Connection failed';
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl
});
}
});
// Log the actual WebSocket URL being used
console.debug(`[NDK.ts] Attempting connection to: ${secureUrl}`);
relay.connect();
});
}
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
* @returns The user's pubkey, or null if there is no logged-in user. * @returns The user's pubkey, or null if there is no logged-in user.
@ -90,22 +321,57 @@ export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'outbox')); localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
} }
/**
* Ensures a relay URL uses secure WebSocket protocol
* @param url The relay URL to secure
* @returns The URL with wss:// protocol
*/
function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present
const secureUrl = url.replace(/^ws:\/\//, 'wss://');
if (secureUrl !== url) {
console.warn(`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`);
}
return secureUrl;
}
/**
* Creates a relay with proper authentication handling
*/
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url);
const relay = new NDKRelay(secureUrl, NDKRelayAuthPolicies.signIn({ ndk }), ndk);
// Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on('connect', () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
authPolicy.authenticate(relay);
});
}
return relay;
}
export function getActiveRelays(ndk: NDK): NDKRelaySet { export function getActiveRelays(ndk: NDK): NDKRelaySet {
// Use anonymous relays if user is not signed in
const isSignedIn = ndk.signer && ndk.activeUser;
const relays = isSignedIn ? standardRelays : anonymousRelays;
return get(feedType) === FeedType.UserRelays return get(feedType) === FeedType.UserRelays
? new NDKRelaySet( ? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => new NDKRelay( new Set(get(inboxRelays).map(relay => createRelayWithAuth(relay, ndk))),
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk ndk
) )
: new NDKRelaySet( : new NDKRelaySet(
new Set(standardRelays.map(relay => new NDKRelay( new Set(relays.map(relay => createRelayWithAuth(relay, ndk))),
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk ndk
); );
} }
@ -121,17 +387,22 @@ export function initNdk(): NDK {
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) ? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null]; : [null, null];
// Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays).map(ensureSecureWebSocket);
console.debug('[NDK.ts] Initializing NDK with relay URLs:', secureRelayUrls);
const ndk = new NDK({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: true,
enableOutboxModel: true, enableOutboxModel: true,
explicitRelayUrls: startingInboxes != null explicitRelayUrls: secureRelayUrls,
? Array.from(startingInboxes.values())
: standardRelays,
}); });
// TODO: Should we prompt the user to confirm authentication? // Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
ndk.connect().then(() => console.debug("ndk connected")); ndk.connect().then(() => console.debug("[NDK.ts] NDK connected"));
return ndk; return ndk;
} }
@ -150,7 +421,7 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
// TODO: Handle changing pubkeys. // TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) { if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('Switching pubkeys from last login.'); console.debug('[NDK.ts] Switching pubkeys from last login.');
} }
activePubkey.set(signerUser.pubkey); activePubkey.set(signerUser.pubkey);
@ -189,6 +460,7 @@ export function logout(user: NDKUser): void {
clearPersistedRelays(user); clearPersistedRelays(user);
activePubkey.set(null); activePubkey.set(null);
ndkSignedIn.set(false); ndkSignedIn.set(false);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
} }
/** /**
@ -220,7 +492,7 @@ async function getUserPreferredRelays(
if (relayList == null) { if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.(); const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => { Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk); const relay = createRelayWithAuth(url, ndk);
if (relayType.read) inboxRelays.add(relay); if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}); });
@ -228,14 +500,14 @@ async function getUserPreferredRelays(
relayList.tags.forEach(tag => { relayList.tags.forEach(tag => {
switch (tag[0]) { switch (tag[0]) {
case 'r': case 'r':
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
case 'w': case 'w':
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
default: default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(createRelayWithAuth(tag[1], ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
} }
}); });

12
src/lib/utils/nostrUtils.ts

@ -4,7 +4,7 @@ import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache'; import { npubCache } from './npubCache';
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
import { sha256 } from '@noble/hashes/sha256'; import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1'; import { schnorr } from '@noble/curves/secp256k1';
@ -320,9 +320,13 @@ export async function fetchEventWithFallback(
.map(r => r.url) : .map(r => r.url) :
[]; [];
// Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser;
const primaryRelays = isSignedIn ? standardRelays : anonymousRelays;
// Create three relay sets in priority order // Create three relay sets in priority order
const relaySets = [ const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in) NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort) NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
]; ];
@ -347,7 +351,7 @@ export async function fetchEventWithFallback(
// Try each relay set in order // Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) { for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? 'standard relays' : const setName = index === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') :
index === 1 ? 'user relays' : index === 1 ? 'user relays' :
'fallback relays'; 'fallback relays';
@ -358,7 +362,7 @@ export async function fetchEventWithFallback(
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => { const relayUrls = relaySets.map((set, i) => {
const setName = i === 0 ? 'standard relays' : const setName = i === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') :
i === 1 ? 'user relays' : i === 1 ? 'user relays' :
'fallback relays'; 'fallback relays';
const urls = Array.from(set.relays).map(r => r.url); const urls = Array.from(set.relays).map(r => r.url);

5
src/routes/about/+page.svelte

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte"; import { Heading, Img, P, A } from "flowbite-svelte";
import RelayStatus from "$lib/components/RelayStatus.svelte";
// Get the git tag version from environment variables // Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development"; const appVersion = import.meta.env.APP_VERSION || "development";
@ -51,5 +52,9 @@
target="_blank">homepage</A target="_blank">homepage</A
> and find out more about us, and the many projects we are working on. > and find out more about us, and the many projects we are working on.
</P> </P>
<div class="border-t pt-6">
<RelayStatus />
</div>
</main> </main>
</div> </div>

Loading…
Cancel
Save