diff --git a/src/core/di-container.ts b/src/core/di-container.ts index fa6e762..6c20e0f 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -144,6 +144,9 @@ export const SERVICE_TOKENS = { SUBMISSION_SERVICE: Symbol('submissionService'), LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), + // Nostr metadata services + NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), + // Nostr transport (kind-21000 RPC over relays — LNbits backend) NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'), diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index 4cc9523..7b4b6fe 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -2,7 +2,9 @@ import { ref, computed } from 'vue' import { BaseService } from '@/core/base/BaseService' import { eventBus } from '@/core/event-bus' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' +import type { NostrMetadataService } from '../nostr/nostr-metadata-service' import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' export class AuthService extends BaseService { @@ -112,6 +114,9 @@ export class AuthService extends BaseService { eventBus.emit('auth:login', { user: userData }, 'auth-service') + // Auto-broadcast Nostr metadata on login + this.broadcastNostrMetadata() + } catch (error) { const err = this.handleError(error, 'login') eventBus.emit('auth:login-failed', { error: err }, 'auth-service') @@ -133,6 +138,9 @@ export class AuthService extends BaseService { eventBus.emit('auth:login', { user: userData }, 'auth-service') + // Auto-broadcast Nostr metadata on registration + this.broadcastNostrMetadata() + } catch (error) { const err = this.handleError(error, 'register') eventBus.emit('auth:login-failed', { error: err }, 'auth-service') @@ -187,9 +195,10 @@ export class AuthService extends BaseService { prvkey: this.user.value?.prvkey || updatedUser.prvkey } - // Kind-0 metadata is published server-side by lnbits's PATCH /auth handler - // (aiolabs/lnbits commit 869f67c3) once the cascade is deployed. The webapp - // no longer maintains its own broadcast path. + // Auto-broadcast Nostr metadata when profile is updated + // Note: ProfileSettings component will also manually broadcast, + // but this ensures metadata stays in sync even if updated elsewhere + this.broadcastNostrMetadata() } catch (error) { const err = this.handleError(error, 'updateProfile') @@ -199,6 +208,26 @@ export class AuthService extends BaseService { } } + /** + * Broadcast user metadata to Nostr relays (NIP-01 kind 0) + * Called automatically on login, registration, and profile updates + */ + private async broadcastNostrMetadata(): Promise { + try { + const metadataService = injectService(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) + if (metadataService && this.user.value?.pubkey) { + // Broadcast in background - don't block login/update + metadataService.publishMetadata().catch(error => { + console.warn('Failed to broadcast Nostr metadata:', error) + // Don't throw - this is a non-critical background operation + }) + } + } catch (error) { + // If service isn't available yet, silently skip + console.debug('Nostr metadata service not yet available') + } + } + /** * Cleanup when service is disposed */ diff --git a/src/modules/base/components/ProfileSettings.vue b/src/modules/base/components/ProfileSettings.vue index 1ad99ef..735a39a 100644 --- a/src/modules/base/components/ProfileSettings.vue +++ b/src/modules/base/components/ProfileSettings.vue @@ -122,17 +122,32 @@ - +
+ + + +

- Your profile is broadcast to Nostr automatically when you save changes. + Your profile is automatically broadcast to Nostr when you update it or log in. + Use the "Broadcast to Nostr" button to manually re-broadcast your profile.

@@ -174,7 +189,7 @@ import * as z from 'zod' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' -import { User, Zap, Hash } from 'lucide-vue-next' +import { User, Zap, Hash, Radio } from 'lucide-vue-next' import { FormControl, FormDescription, @@ -200,16 +215,19 @@ import { useAuth } from '@/composables/useAuthService' import { useRouter } from 'vue-router' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { ImageUploadService } from '../services/ImageUploadService' +import type { NostrMetadataService } from '../nostr/nostr-metadata-service' import { useToast } from '@/core/composables/useToast' // Services const { user, updateProfile, logout } = useAuth() const router = useRouter() const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) +const metadataService = injectService(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) const toast = useToast() // Local state const isUpdating = ref(false) +const isBroadcasting = ref(false) const updateError = ref(null) const updateSuccess = ref(false) const uploadedPicture = ref([]) @@ -305,12 +323,18 @@ const updateUserProfile = async (formData: any) => { } } - // Update profile via AuthService (which updates LNbits). - // Kind-0 metadata publishing happens server-side as part of the - // PATCH /api/v1/auth handler (aiolabs/lnbits 869f67c3). + // Update profile via AuthService (which updates LNbits) await updateProfile(updateData) - toast.success('Profile updated!') + // Broadcast to Nostr automatically + try { + await metadataService.publishMetadata() + toast.success('Profile updated and broadcast to Nostr!') + } catch (nostrError) { + console.error('Failed to broadcast to Nostr:', nostrError) + toast.warning('Profile updated, but failed to broadcast to Nostr') + } + updateSuccess.value = true // Clear success message after 3 seconds @@ -328,6 +352,22 @@ const updateUserProfile = async (formData: any) => { } } +// Manually broadcast metadata to Nostr +const broadcastMetadata = async () => { + isBroadcasting.value = true + + try { + const result = await metadataService.publishMetadata() + toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata' + console.error('Error broadcasting metadata:', error) + toast.error(`Failed to broadcast: ${errorMessage}`) + } finally { + isBroadcasting.value = false + } +} + // Log out + redirect to /login on this app's origin. const onLogout = async () => { try { diff --git a/src/modules/base/composables/useNostrMetadata.ts b/src/modules/base/composables/useNostrMetadata.ts new file mode 100644 index 0000000..dac17a4 --- /dev/null +++ b/src/modules/base/composables/useNostrMetadata.ts @@ -0,0 +1,39 @@ +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service' + +/** + * Composable for accessing Nostr metadata service + * + * @example + * ```ts + * const { publishMetadata, getMetadata } = useNostrMetadata() + * + * // Get current metadata + * const metadata = getMetadata() + * + * // Publish metadata to Nostr relays + * await publishMetadata() + * ``` + */ +export function useNostrMetadata() { + const metadataService = injectService(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) + + /** + * Publish user metadata to Nostr relays (NIP-01 kind 0) + */ + const publishMetadata = async (): Promise<{ success: number; total: number }> => { + return await metadataService.publishMetadata() + } + + /** + * Get current user's Nostr metadata + */ + const getMetadata = (): NostrMetadata => { + return metadataService.getMetadata() + } + + return { + publishMetadata, + getMetadata + } +} diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index c021e4f..16b9fbc 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -2,6 +2,7 @@ import type { App } from 'vue' import type { ModulePlugin } from '@/core/types' import { container, SERVICE_TOKENS } from '@/core/di-container' import { relayHub } from './nostr/relay-hub' +import { NostrMetadataService } from './nostr/nostr-metadata-service' import { ProfileService } from './nostr/ProfileService' import { ReactionService } from './nostr/ReactionService' import { NostrTransportService } from './services/NostrTransportService' @@ -29,6 +30,7 @@ import ProfileSettings from './components/ProfileSettings.vue' const invoiceService = new InvoiceService() const lnbitsAPI = new LnbitsAPI() const imageUploadService = new ImageUploadService() +const nostrMetadataService = new NostrMetadataService() const profileService = new ProfileService() const reactionService = new ReactionService() const nostrTransportService = new NostrTransportService() @@ -46,6 +48,7 @@ export const baseModule: ModulePlugin = { // Register core Nostr services container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub) + container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService) // Register auth service container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) @@ -110,6 +113,10 @@ export const baseModule: ModulePlugin = { waitForDependencies: true, // ImageUploadService depends on ToastService maxRetries: 3 }) + await nostrMetadataService.initialize({ + waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub + maxRetries: 3 + }) await profileService.initialize({ waitForDependencies: true, // ProfileService depends on RelayHub maxRetries: 3 @@ -138,6 +145,7 @@ export const baseModule: ModulePlugin = { await storageService.dispose() await toastService.dispose() await imageUploadService.dispose() + await nostrMetadataService.dispose() await profileService.dispose() await reactionService.dispose() await nostrTransportService.dispose() @@ -148,6 +156,7 @@ export const baseModule: ModulePlugin = { container.remove(SERVICE_TOKENS.LNBITS_API) container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) + container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) container.remove(SERVICE_TOKENS.PROFILE_SERVICE) container.remove(SERVICE_TOKENS.REACTION_SERVICE) @@ -164,6 +173,7 @@ export const baseModule: ModulePlugin = { invoiceService, pwaService, imageUploadService, + nostrMetadataService, profileService, reactionService }, diff --git a/src/modules/base/nostr/nostr-metadata-service.ts b/src/modules/base/nostr/nostr-metadata-service.ts new file mode 100644 index 0000000..16e77f6 --- /dev/null +++ b/src/modules/base/nostr/nostr-metadata-service.ts @@ -0,0 +1,162 @@ +import { BaseService } from '@/core/base/BaseService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { finalizeEvent, type EventTemplate } from 'nostr-tools' +import type { AuthService } from '@/modules/base/auth/auth-service' +import type { RelayHub } from '@/modules/base/nostr/relay-hub' + +/** + * Nostr User Metadata (NIP-01 kind 0) + * https://github.com/nostr-protocol/nips/blob/master/01.md + */ +export interface NostrMetadata { + name?: string // Display name (from username) + display_name?: string // Alternative display name + about?: string // Bio/description + picture?: string // Profile picture URL + banner?: string // Profile banner URL + nip05?: string // NIP-05 identifier (username@domain) + lud16?: string // Lightning Address (same as nip05) + website?: string // Personal website +} + +/** + * Service for publishing and managing Nostr user metadata (NIP-01 kind 0) + * + * This service handles: + * - Publishing user profile metadata to Nostr relays + * - Syncing LNbits user data with Nostr profile + * - Auto-broadcasting metadata on login and profile updates + */ +export class NostrMetadataService extends BaseService { + protected readonly metadata = { + name: 'NostrMetadataService', + version: '1.0.0', + dependencies: ['AuthService', 'RelayHub'] + } + + protected authService: AuthService | null = null + protected relayHub: RelayHub | null = null + + protected async onInitialize(): Promise { + console.log('NostrMetadataService: Starting initialization...') + + this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) + this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + + if (!this.authService) { + throw new Error('AuthService not available') + } + + if (!this.relayHub) { + throw new Error('RelayHub service not available') + } + + console.log('NostrMetadataService: Initialization complete') + } + + /** + * Build Nostr metadata from LNbits user data + */ + private buildMetadata(): NostrMetadata { + const user = this.authService?.user.value + if (!user) { + throw new Error('No authenticated user') + } + + const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname + const username = user.username || user.id.slice(0, 8) + + const metadata: NostrMetadata = { + name: username, + nip05: `${username}@${lightningDomain}`, + lud16: `${username}@${lightningDomain}` + } + + // Add optional fields from user.extra if they exist + if (user.extra?.display_name) { + metadata.display_name = user.extra.display_name + } + + if (user.extra?.picture) { + metadata.picture = user.extra.picture + } + + return metadata + } + + /** + * Publish user metadata to Nostr relays (NIP-01 kind 0) + * + * This creates a replaceable event that updates the user's profile. + * Only the latest kind 0 event for a given pubkey is kept by relays. + */ + async publishMetadata(): Promise<{ success: number; total: number }> { + if (!this.authService?.isAuthenticated.value) { + throw new Error('Must be authenticated to publish metadata') + } + + if (!this.relayHub?.isConnected.value) { + throw new Error('Not connected to relays') + } + + const user = this.authService.user.value + if (!user?.prvkey) { + throw new Error('User private key not available') + } + + try { + const metadata = this.buildMetadata() + + console.log('📤 Publishing Nostr metadata (kind 0):', metadata) + + // Create kind 0 event (user metadata) + // Content is JSON-stringified metadata + const eventTemplate: EventTemplate = { + kind: 0, + content: JSON.stringify(metadata), + tags: [], + created_at: Math.floor(Date.now() / 1000) + } + + // Sign the event + const privkeyBytes = this.hexToUint8Array(user.prvkey) + const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) + + console.log('✅ Metadata event signed:', signedEvent.id) + console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2)) + + // Publish to all connected relays + const result = await this.relayHub.publishEvent(signedEvent) + + console.log(`✅ Metadata published to ${result.success}/${result.total} relays`) + + return result + + } catch (error) { + console.error('Failed to publish metadata:', error) + throw error + } + } + + /** + * Get current user's Nostr metadata + */ + getMetadata(): NostrMetadata { + return this.buildMetadata() + } + + /** + * Helper function to convert hex string to Uint8Array + */ + private hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes + } + + protected async onDestroy(): Promise { + // Cleanup if needed + } +}