From 414b79565c6e022a487d448e2f461a92024b8f9e Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:28:48 +0200 Subject: [PATCH 1/3] chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/core/di-container.ts | 3 - src/modules/base/auth/auth-service.ts | 35 +--- .../base/components/ProfileSettings.vue | 68 ++------ .../base/composables/useNostrMetadata.ts | 39 ----- src/modules/base/index.ts | 10 -- .../base/nostr/nostr-metadata-service.ts | 162 ------------------ 6 files changed, 17 insertions(+), 300 deletions(-) delete mode 100644 src/modules/base/composables/useNostrMetadata.ts delete mode 100644 src/modules/base/nostr/nostr-metadata-service.ts diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 6c20e0f..fa6e762 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -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'), diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index 7b4b6fe..4cc9523 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -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 { - 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 735a39a..1ad99ef 100644 --- a/src/modules/base/components/ProfileSettings.vue +++ b/src/modules/base/components/ProfileSettings.vue @@ -122,32 +122,17 @@ -
- - - -
+

- 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.

@@ -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(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([]) @@ -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 { diff --git a/src/modules/base/composables/useNostrMetadata.ts b/src/modules/base/composables/useNostrMetadata.ts deleted file mode 100644 index dac17a4..0000000 --- a/src/modules/base/composables/useNostrMetadata.ts +++ /dev/null @@ -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(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 16b9fbc..c021e4f 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -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 }, diff --git a/src/modules/base/nostr/nostr-metadata-service.ts b/src/modules/base/nostr/nostr-metadata-service.ts deleted file mode 100644 index 16e77f6..0000000 --- a/src/modules/base/nostr/nostr-metadata-service.ts +++ /dev/null @@ -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 { - 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 - } -} From 26a661891a22d81293f1630f6b93fa11c06d05a8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 2/3] chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aiolabs/events extension on its signer-abstraction branch (commit 66076d6) constructs and publishes kind-31922 NIP-52 calendar events server-side via NostrSigner — POST /events/api/v1/events accepts a CreateEventRequest payload, signs through the operator's signer, and broadcasts to configured relays. The webapp no longer needs to sign calendar events client-side. Changes: - ActivitiesNostrService.ts: delete publishCalendarEvent() and its helper imports (finalizeEvent, EventTemplate, buildCalendarTimeEventTags, the local hexToUint8Array). The subscribe / query paths stay — the service still reads NIP-52 events off relays for the activity feed. Docstring updated to reflect the read-only role and point at the events extension for the publish path. - CreateActivityDialog.vue: swap the publish flow. - Drop ActivitiesNostrService injection + currentUser.value.prvkey read. - Inject TicketApiService instead; pull invoiceKey from currentUser.value.wallets[0].inkey (same pattern as EventsPage.vue handleCreateEvent). - Build CreateEventRequest with amount_tickets: 0, price_per_ticket: 0 (events extension treats 0 as unlimited/not-ticketed per models.py:45-46 per lnbits 22:30Z audit). - Fold summary + description into the events extension's `info` field since CreateEventRequest has no separate summary slot. - Update toast on success to "Activity created!" (server publishes to relays via the signer, not the webapp). Approval-workflow caveat documented inline in the submit handler: non-admin users on instances with auto_approve=false (the default) land in the proposal queue and don't publish to relays until an admin approves. Admins / auto_approve=true instances publish immediately. This is the intended new behavior — operators can flip auto_approve on the events extension config per-instance if they want the legacy direct-publish moderation posture. This is webapp's second bucket-A leg per aiolabs/lnbits#9 phase-1. The remaining `currentUser.value.prvkey` reads stay until the atomic User.prvkey field-removal PR (Q1.2 Option (b)). Refs: - log:2026-05-28T22:30Z (lnbits Q2.1 audit verifying ticket-less acceptance + approval-workflow caveat) - ~/dev/coordination/webapp-design-questions.md Q2.1 - aiolabs/events signer-abstraction commit 66076d6 (the server-side publish path) - aiolabs/lnbits cascade tip 861f427c deployed to aio-demo Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateActivityDialog.vue | 84 ++++++++++--------- .../services/ActivitiesNostrService.ts | 48 +++-------- 2 files changed, 56 insertions(+), 76 deletions(-) diff --git a/src/modules/activities/components/CreateActivityDialog.vue b/src/modules/activities/components/CreateActivityDialog.vue index 0c6935a..2c5654c 100644 --- a/src/modules/activities/components/CreateActivityDialog.vue +++ b/src/modules/activities/components/CreateActivityDialog.vue @@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button' import { CalendarPlus } from 'lucide-vue-next' import { useAuth } from '@/composables/useAuthService' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' -import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' -import type { CalendarTimeEvent } from '../types/nip52' +import type { TicketApiService } from '../services/TicketApiService' +import type { CreateEventRequest } from '../types/ticket' import type { ActivityCategory } from '../types/category' import CategorySelector from './CategorySelector.vue' import LocationPicker from './LocationPicker.vue' @@ -67,56 +67,64 @@ const form = useForm({ const isFormValid = computed(() => form.meta.value.valid) const onSubmit = form.handleSubmit(async (values) => { - const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) - if (!nostrService) { + const ticketApi = tryInjectService(SERVICE_TOKENS.TICKET_API) + if (!ticketApi) { toast.error('Activities service not available') return } - const signingKey = currentUser.value?.prvkey - if (!signingKey) { - toast.error('Signing key not available. Please log in again.') + const invoiceKey = currentUser.value?.wallets?.[0]?.inkey + if (!invoiceKey) { + toast.error('No wallet available. Please log in first.') return } isPublishing.value = true try { - // Build unix timestamps - const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) - let endTimestamp: number | undefined - if (values.endDate && values.endTime) { - endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) + // Compose ISO 8601 datetime strings the events extension parses. + const startIso = `${values.startDate}T${values.startTime}` + const endIso = + values.endDate && values.endTime + ? `${values.endDate}T${values.endTime}` + : undefined + + // Fold summary + description into `info` since the events extension + // CreateEventRequest has no separate summary field. + const info = + values.summary && values.description + ? `${values.summary}\n\n${values.description}` + : values.description || values.summary || '' + + // Ticket-less activity — amount_tickets and price_per_ticket both + // pinned at 0 (events extension treats 0 as "unlimited / not + // ticketed" per models.py:45-46). Server-side `signer.sign_event` + // produces the kind-31922 calendar event and publishes via the + // operator's configured relays — no webapp signing path needed. + const eventData: CreateEventRequest = { + name: values.title, + info, + event_start_date: startIso, + event_end_date: endIso, + location: location.value || null, + banner: values.image || null, + categories: selectedCategories.value, + amount_tickets: 0, + price_per_ticket: 0, } - // Generate a unique d-tag - const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + await ticketApi.createEvent(eventData, invoiceKey) - const eventData: Partial = { - dTag, - title: values.title, - summary: values.summary || undefined, - content: values.description, - image: values.image || undefined, - start: startTimestamp, - end: endTimestamp, - startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone, - location: location.value || undefined, - hashtags: selectedCategories.value, - } - - const result = await nostrService.publishCalendarEvent(eventData, signingKey) - - if (result.success > 0) { - toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`) - emit('created') - handleClose() - } else { - toast.error('Failed to publish to any relay') - } + // Approval workflow caveat: non-admin users on instances with + // `auto_approve=false` (the default) land in the proposal queue; + // their event isn't published to relays until an admin approves. + // Admins-and-auto-approve-on instances publish immediately. + toast.success('Activity created!') + emit('created') + handleClose() } catch (err) { - console.error('Failed to publish activity:', err) - toast.error(err instanceof Error ? err.message : 'Failed to publish activity') + console.error('Failed to create activity:', err) + toast.error(err instanceof Error ? err.message : 'Failed to create activity') } finally { isPublishing.value = false } diff --git a/src/modules/activities/services/ActivitiesNostrService.ts b/src/modules/activities/services/ActivitiesNostrService.ts index f098728..0b0a00e 100644 --- a/src/modules/activities/services/ActivitiesNostrService.ts +++ b/src/modules/activities/services/ActivitiesNostrService.ts @@ -1,12 +1,10 @@ import { BaseService } from '@/core/base/BaseService' -import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools' +import type { Event as NostrEvent } from 'nostr-tools' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import { NIP52_KINDS, parseCalendarTimeEvent, parseCalendarDateEvent, - buildCalendarTimeEventTags, - type CalendarTimeEvent, } from '../types/nip52' import { calendarTimeEventToActivity, @@ -28,7 +26,15 @@ export interface CalendarEventFilters { } /** - * Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. + * Service for subscribing to NIP-52 Calendar Events via RelayHub. + * + * Publishing kind-31922 calendar events lives server-side in the + * `aiolabs/events` LNbits extension (signer-abstraction branch, commit + * 66076d6) — `POST /events/api/v1/events` constructs and signs the + * event via NostrSigner and broadcasts it to the operator's configured + * relays. The webapp constructs only the request payload; see + * CreateActivityDialog for the flow. + * * Extends BaseService for standardized dependency injection and lifecycle. */ export class ActivitiesNostrService extends BaseService { @@ -105,32 +111,6 @@ export class ActivitiesNostrService extends BaseService { return activities } - /** - * Publish a NIP-52 time-based calendar event. - * Requires an authenticated user with a signing key. - */ - async publishCalendarEvent( - eventData: Partial, - signingKeyHex: string - ): Promise<{ success: number; total: number }> { - if (!this.relayHub) { - throw new Error('RelayHub not available') - } - - const tags = buildCalendarTimeEventTags(eventData) - const template: EventTemplate = { - kind: NIP52_KINDS.CALENDAR_TIME_EVENT, - created_at: Math.floor(Date.now() / 1000), - content: eventData.content ?? '', - tags, - } - - const privkeyBytes = hexToUint8Array(signingKeyHex) - const signedEvent = finalizeEvent(template, privkeyBytes) - - return await this.relayHub.publishEvent(signedEvent) - } - /** * Parse a raw Nostr event into an Activity view model. */ @@ -179,11 +159,3 @@ export class ActivitiesNostrService extends BaseService { this.activeUnsubscribes = [] } } - -function 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 -} From 531de18ad764bc475a34d4d22180fd11a5d1f094 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 3/3] chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aiolabs/events extension on its signer-abstraction branch (commit 66076d6) constructs and publishes kind-31922 NIP-52 calendar events server-side via NostrSigner — POST /events/api/v1/events accepts a CreateEventRequest payload, signs through the operator's signer, and broadcasts to configured relays. The webapp no longer needs to sign calendar events client-side. Changes: - ActivitiesNostrService.ts: delete publishCalendarEvent() and its helper imports (finalizeEvent, EventTemplate, buildCalendarTimeEventTags, the local hexToUint8Array). The subscribe / query paths stay — the service still reads NIP-52 events off relays for the activity feed. Docstring updated to reflect the read-only role and point at the events extension for the publish path. - CreateActivityDialog.vue: swap the publish flow. - Drop ActivitiesNostrService injection + currentUser.value.prvkey read. - Inject TicketApiService instead; pull invoiceKey from currentUser.value.wallets[0].inkey (same pattern as EventsPage.vue handleCreateEvent). - Build CreateEventRequest with amount_tickets: 0, price_per_ticket: 0 (events extension treats 0 as unlimited/not-ticketed per models.py:45-46 per lnbits 22:30Z audit). - Fold summary + description into the events extension's `info` field since CreateEventRequest has no separate summary slot. - Update toast on success to "Activity created!" (server publishes to relays via the signer, not the webapp). Approval-workflow caveat documented inline in the submit handler: non-admin users on instances with auto_approve=false (the default) land in the proposal queue and don't publish to relays until an admin approves. Admins / auto_approve=true instances publish immediately. This is the intended new behavior — operators can flip auto_approve on the events extension config per-instance if they want the legacy direct-publish moderation posture. This is webapp's second bucket-A leg per aiolabs/lnbits#9 phase-1. The remaining `currentUser.value.prvkey` reads stay until the atomic User.prvkey field-removal PR (Q1.2 Option (b)). Refs: - log:2026-05-28T22:30Z (lnbits Q2.1 audit verifying ticket-less acceptance + approval-workflow caveat) - ~/dev/coordination/webapp-design-questions.md Q2.1 - aiolabs/events signer-abstraction commit 66076d6 (the server-side publish path) - aiolabs/lnbits cascade tip 861f427c deployed to aio-demo Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateActivityDialog.vue | 84 ++++++++++--------- .../services/ActivitiesNostrService.ts | 48 +++-------- 2 files changed, 56 insertions(+), 76 deletions(-) diff --git a/src/modules/activities/components/CreateActivityDialog.vue b/src/modules/activities/components/CreateActivityDialog.vue index 0c6935a..2c5654c 100644 --- a/src/modules/activities/components/CreateActivityDialog.vue +++ b/src/modules/activities/components/CreateActivityDialog.vue @@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button' import { CalendarPlus } from 'lucide-vue-next' import { useAuth } from '@/composables/useAuthService' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' -import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' -import type { CalendarTimeEvent } from '../types/nip52' +import type { TicketApiService } from '../services/TicketApiService' +import type { CreateEventRequest } from '../types/ticket' import type { ActivityCategory } from '../types/category' import CategorySelector from './CategorySelector.vue' import LocationPicker from './LocationPicker.vue' @@ -67,56 +67,64 @@ const form = useForm({ const isFormValid = computed(() => form.meta.value.valid) const onSubmit = form.handleSubmit(async (values) => { - const nostrService = tryInjectService(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) - if (!nostrService) { + const ticketApi = tryInjectService(SERVICE_TOKENS.TICKET_API) + if (!ticketApi) { toast.error('Activities service not available') return } - const signingKey = currentUser.value?.prvkey - if (!signingKey) { - toast.error('Signing key not available. Please log in again.') + const invoiceKey = currentUser.value?.wallets?.[0]?.inkey + if (!invoiceKey) { + toast.error('No wallet available. Please log in first.') return } isPublishing.value = true try { - // Build unix timestamps - const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) - let endTimestamp: number | undefined - if (values.endDate && values.endTime) { - endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) + // Compose ISO 8601 datetime strings the events extension parses. + const startIso = `${values.startDate}T${values.startTime}` + const endIso = + values.endDate && values.endTime + ? `${values.endDate}T${values.endTime}` + : undefined + + // Fold summary + description into `info` since the events extension + // CreateEventRequest has no separate summary field. + const info = + values.summary && values.description + ? `${values.summary}\n\n${values.description}` + : values.description || values.summary || '' + + // Ticket-less activity — amount_tickets and price_per_ticket both + // pinned at 0 (events extension treats 0 as "unlimited / not + // ticketed" per models.py:45-46). Server-side `signer.sign_event` + // produces the kind-31922 calendar event and publishes via the + // operator's configured relays — no webapp signing path needed. + const eventData: CreateEventRequest = { + name: values.title, + info, + event_start_date: startIso, + event_end_date: endIso, + location: location.value || null, + banner: values.image || null, + categories: selectedCategories.value, + amount_tickets: 0, + price_per_ticket: 0, } - // Generate a unique d-tag - const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + await ticketApi.createEvent(eventData, invoiceKey) - const eventData: Partial = { - dTag, - title: values.title, - summary: values.summary || undefined, - content: values.description, - image: values.image || undefined, - start: startTimestamp, - end: endTimestamp, - startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone, - location: location.value || undefined, - hashtags: selectedCategories.value, - } - - const result = await nostrService.publishCalendarEvent(eventData, signingKey) - - if (result.success > 0) { - toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`) - emit('created') - handleClose() - } else { - toast.error('Failed to publish to any relay') - } + // Approval workflow caveat: non-admin users on instances with + // `auto_approve=false` (the default) land in the proposal queue; + // their event isn't published to relays until an admin approves. + // Admins-and-auto-approve-on instances publish immediately. + toast.success('Activity created!') + emit('created') + handleClose() } catch (err) { - console.error('Failed to publish activity:', err) - toast.error(err instanceof Error ? err.message : 'Failed to publish activity') + console.error('Failed to create activity:', err) + toast.error(err instanceof Error ? err.message : 'Failed to create activity') } finally { isPublishing.value = false } diff --git a/src/modules/activities/services/ActivitiesNostrService.ts b/src/modules/activities/services/ActivitiesNostrService.ts index f098728..0b0a00e 100644 --- a/src/modules/activities/services/ActivitiesNostrService.ts +++ b/src/modules/activities/services/ActivitiesNostrService.ts @@ -1,12 +1,10 @@ import { BaseService } from '@/core/base/BaseService' -import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools' +import type { Event as NostrEvent } from 'nostr-tools' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import { NIP52_KINDS, parseCalendarTimeEvent, parseCalendarDateEvent, - buildCalendarTimeEventTags, - type CalendarTimeEvent, } from '../types/nip52' import { calendarTimeEventToActivity, @@ -28,7 +26,15 @@ export interface CalendarEventFilters { } /** - * Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. + * Service for subscribing to NIP-52 Calendar Events via RelayHub. + * + * Publishing kind-31922 calendar events lives server-side in the + * `aiolabs/events` LNbits extension (signer-abstraction branch, commit + * 66076d6) — `POST /events/api/v1/events` constructs and signs the + * event via NostrSigner and broadcasts it to the operator's configured + * relays. The webapp constructs only the request payload; see + * CreateActivityDialog for the flow. + * * Extends BaseService for standardized dependency injection and lifecycle. */ export class ActivitiesNostrService extends BaseService { @@ -105,32 +111,6 @@ export class ActivitiesNostrService extends BaseService { return activities } - /** - * Publish a NIP-52 time-based calendar event. - * Requires an authenticated user with a signing key. - */ - async publishCalendarEvent( - eventData: Partial, - signingKeyHex: string - ): Promise<{ success: number; total: number }> { - if (!this.relayHub) { - throw new Error('RelayHub not available') - } - - const tags = buildCalendarTimeEventTags(eventData) - const template: EventTemplate = { - kind: NIP52_KINDS.CALENDAR_TIME_EVENT, - created_at: Math.floor(Date.now() / 1000), - content: eventData.content ?? '', - tags, - } - - const privkeyBytes = hexToUint8Array(signingKeyHex) - const signedEvent = finalizeEvent(template, privkeyBytes) - - return await this.relayHub.publishEvent(signedEvent) - } - /** * Parse a raw Nostr event into an Activity view model. */ @@ -179,11 +159,3 @@ export class ActivitiesNostrService extends BaseService { this.activeUnsubscribes = [] } } - -function 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 -}