Compare commits

...

4 commits

Author SHA1 Message Date
bc565ebf4b Merge pull request 'chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths' (#82) from chore/delete-nostr-metadata-service into dev
Reviewed-on: #82
2026-05-30 15:25:47 +00:00
cb6e1351fb fix(activities): scope detail-page query by NIP-52 d-tag
`useActivityDetail.load()` previously asked every relay for every
kind-31922/31923 event and raced a 5s timeout to find the one
matching the route param. On a cold refresh of the detail page, the
race was often lost — the store starts empty (no feed subscription
to populate it), the relay sprays the whole calendar, and the
matching event may arrive after the timeout, leaving the user with
"Activity not found" on a valid URL.

Add a `dTags` field to `CalendarEventFilters` and emit it as the
nostr `#d` tag filter. Detail-page subscribe + query both scope to
the single activity, so the relay resolves a parameterized-replaceable
lookup in milliseconds instead of streaming the whole calendar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:03:55 +02:00
261eded316 fix(api): align webapp client with post-cascade lnbits + surface error detail
Two fixes that together make the kind-0 server-side publish path (this
PR's whole reason for existing) actually work end-to-end against the
deployed cascade:

1. **updateProfile() uses PATCH /api/v1/auth, not PUT /auth/update.**
   aiolabs/lnbits PR #26 gap-fill (869f67c3) wired
   _publish_nostr_metadata_event into the PATCH handler at
   auth_api.py:546. The legacy `/auth/update` route doesn't exist on
   the post-cascade server — a `PUT /auth/update` request gets routed
   into the `/auth/{provider}` SSO wildcard which only allows GET and
   returns 405. Caught while smoke-testing this PR against a local
   regtest pointed at the issue-18-phase-2.3 branch.

2. **request<T>() parses FastAPI's `{"detail": "..."}` error shape.**
   The old error path threw `API request failed: 405 Method Not Allowed`
   for the regtest's 405 above — useful only if you also opened the
   network panel and read the response body manually. Now we parse the
   detail (string or pydantic-validation array), include the endpoint
   path, and throw `LNbits /auth 405: Method Not Allowed`. Falls back
   to raw text for non-JSON bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 07:45:35 +02:00
414b79565c chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths
Lnbits's cascade now publishes kind-0 user metadata server-side on
account creation AND on every PATCH /api/v1/auth (aiolabs/lnbits commit
869f67c3 folded into PR #26, deployed to aio-demo via server-deploy
e2eed9c). The webapp no longer needs its own kind-0 publish surface.

Changes:
- Delete src/modules/base/nostr/nostr-metadata-service.ts (162 lines).
  Server now owns kind-0 lifecycle via NostrSigner.sign_event.
- Delete src/modules/base/composables/useNostrMetadata.ts (had zero
  callers; was just a thin wrapper around the deleted service).
- Remove NOSTR_METADATA_SERVICE token from di-container.ts.
- Remove all NostrMetadataService imports / instantiations /
  registrations / dispose calls from src/modules/base/index.ts.
- src/modules/base/auth/auth-service.ts:
  - Drop the broadcastNostrMetadata() helper entirely.
  - Drop its callers in login() (was line 118 pre-edit) and register()
    (was line 142 pre-edit) — both flagged for removal by lnbits in the
    01:45Z coordination handoff. Login-time republish was always
    redundant for kind-0 (replaceable event); register-time is covered
    by lnbits's create_user_account -> _publish_nostr_metadata_event
    path.
  - Drop the auto-broadcast in updateProfile() too — covered by the
    PATCH /api/v1/auth handler's _publish_nostr_metadata_event call
    per the gap-fill commit.
  - Leave the prvkey/pubkey preservation in updateProfile() in place
    for now; the prvkey field removal is the atomic phase-1 final PR
    per design doc Q1.2 Option (b).
- src/modules/base/components/ProfileSettings.vue:
  - Remove the "Broadcast to Nostr" button + isBroadcasting state +
    Radio icon + broadcastMetadata() handler. Manual re-broadcast was
    a local-testing safety net for relay resets that's no longer
    needed once the server publishes automatically on profile save.
  - Simplify the post-save toast to a generic "Profile updated!".
  - Update the helper text accordingly.

This is webapp's bucket-A leg per aiolabs/lnbits#9 phase-1 plan.

Refs:
- log:2026-05-29T01:45Z (lnbits handoff identifying the auth-service
  line numbers to drop)
- ~/dev/coordination/webapp-design-questions.md Q2.3 (decision context)
- aiolabs/lnbits PR #26 commit 869f67c3 (server-side kind-0 publish)
- aiolabs/lnbits dev tip 861f427c, deployed to aio-demo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:38:01 +02:00
9 changed files with 55 additions and 308 deletions

View file

@ -144,9 +144,6 @@ 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'),

View file

@ -112,12 +112,29 @@ export class LnbitsAPI extends BaseService {
if (!response.ok) {
const errorText = await response.text()
// Try to surface FastAPI's `{"detail": "..."}` shape; fall back to raw
// body for non-JSON errors. Without this, every backend error renders
// as a generic "API request failed: <status>" and you can't distinguish
// "wrong endpoint" from "expired token" from "validation failure".
let detail: string = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed && typeof parsed.detail === 'string') {
detail = parsed.detail
} else if (parsed && Array.isArray(parsed.detail)) {
// pydantic ValidationError: take the first msg
detail = parsed.detail[0]?.msg ?? errorText
}
} catch {
// body wasn't JSON; keep the raw text in `detail`
}
console.error('LNBits API Error:', {
endpoint,
status: response.status,
statusText: response.statusText,
errorText
detail,
})
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
throw new Error(`LNbits ${endpoint} ${response.status}: ${detail || response.statusText}`)
}
const data = await response.json()
@ -186,8 +203,12 @@ export class LnbitsAPI extends BaseService {
}
async updateProfile(data: Partial<User>): Promise<User> {
return this.request<User>('/auth/update', {
method: 'PUT',
// aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired
// _publish_nostr_metadata_event into PATCH /api/v1/auth
// (auth_api.py:546). The legacy PUT /auth/update route does not
// exist on the post-cascade server.
return this.request<User>('/auth', {
method: 'PATCH',
body: JSON.stringify(data),
})
}

View file

@ -32,18 +32,24 @@ export function useActivityDetail(activityId: string) {
isLoading.value = true
error.value = null
// Subscribe and wait for this specific event
// Scope both the subscription and the one-shot query to this
// activity's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the activity is reachable.
const detailFilters = { dTags: [activityId] }
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertActivity(incoming)
if (incoming.id === activityId) {
isLoading.value = false
}
}
},
detailFilters
)
// Also do a one-shot query
const results = await nostrService.queryCalendarEvents()
const results = await nostrService.queryCalendarEvents(detailFilters)
store.upsertActivities(results)
// If we still don't have it after query, stop loading

View file

@ -25,6 +25,8 @@ export interface CalendarEventFilters {
hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */
geohash?: string
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
dTags?: string[]
}
/**
@ -168,6 +170,7 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter]
}

View file

@ -2,9 +2,7 @@
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 {
@ -114,9 +112,6 @@ 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')
@ -138,9 +133,6 @@ 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')
@ -195,10 +187,9 @@ export class AuthService extends BaseService {
prvkey: this.user.value?.prvkey || updatedUser.prvkey
}
// 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()
// 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.
} catch (error) {
const err = this.handleError(error, 'updateProfile')
@ -208,26 +199,6 @@ 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<void> {
try {
const metadataService = injectService<NostrMetadataService>(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
*/

View file

@ -122,32 +122,17 @@
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<Button
type="submit"
:disabled="isUpdating || !isFormValid"
class="flex-1"
>
<span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span>
</Button>
<Button
type="button"
variant="outline"
:disabled="isBroadcasting"
@click="broadcastMetadata"
class="flex-1"
>
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
<span v-if="isBroadcasting">Broadcasting...</span>
<span v-else>Broadcast to Nostr</span>
</Button>
</div>
<Button
type="submit"
:disabled="isUpdating || !isFormValid"
class="w-full"
>
<span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span>
</Button>
<p class="text-xs text-muted-foreground">
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.
Your profile is broadcast to Nostr automatically when you save changes.
</p>
</form>
@ -189,7 +174,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, Radio } from 'lucide-vue-next'
import { User, Zap, Hash } from 'lucide-vue-next'
import {
FormControl,
FormDescription,
@ -215,19 +200,16 @@ 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<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast()
// Local state
const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null)
const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([])
@ -323,18 +305,12 @@ const updateUserProfile = async (formData: any) => {
}
}
// Update profile via AuthService (which updates LNbits)
// 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).
await updateProfile(updateData)
// 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')
}
toast.success('Profile updated!')
updateSuccess.value = true
// Clear success message after 3 seconds
@ -352,22 +328,6 @@ 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 {

View file

@ -1,39 +0,0 @@
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<NostrMetadataService>(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
}
}

View file

@ -2,7 +2,6 @@ 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'
@ -30,7 +29,6 @@ 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()
@ -48,7 +46,6 @@ 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)
@ -113,10 +110,6 @@ 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
@ -145,7 +138,6 @@ 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()
@ -156,7 +148,6 @@ 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)
@ -173,7 +164,6 @@ export const baseModule: ModulePlugin = {
invoiceService,
pwaService,
imageUploadService,
nostrMetadataService,
profileService,
reactionService
},

View file

@ -1,162 +0,0 @@
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<void> {
console.log('NostrMetadataService: Starting initialization...')
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
this.relayHub = injectService<RelayHub>(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<void> {
// Cleanup if needed
}
}