diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index bdd2e18..700df9a 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -40,8 +40,12 @@ interface User { username?: string email?: string pubkey?: string - // pragma: allowlist secret - prvkey?: string // Nostr signing key for user + // The `prvkey` field was removed from this interface as the final step of + // phase-1 per aiolabs/lnbits#9 / design-questions Q1.2 Option (b). LNbits + // signs server-side via the NostrSigner abstraction (PR #26) and exposes + // `signer_type` instead of raw key material on /api/v1/auth. Bucket-B + // sign-sites (kind 1 / 4 / 5 / 7 / 31925 / 10003 / 1111 etc.) migrate to + // POST /api/v1/auth/sign-event (PR #29) in phase 2. external_id?: string extensions: string[] wallets: Wallet[] @@ -174,20 +178,22 @@ export class LnbitsAPI extends BaseService { async getCurrentUser(): Promise { // First get basic user info from /auth const basicUser = await this.request('/auth') - - // Then get Nostr keys from /auth/nostr/me (this was working in main branch) + + // /auth/nostr/me used to return the user's prvkey for client-side signing; + // post-aiolabs/lnbits#9 phase-1 the server signs and the endpoint returns + // only the pubkey. We keep the call to merge the pubkey (which the basic + // /auth response also includes on the post-cascade server; this is the + // belt-and-suspenders fallback for older lnbits revisions until we ship a + // signer_type-aware client). try { const nostrUser = await this.request('/auth/nostr/me') - - // Merge the data - basic user info + Nostr keys + return { ...basicUser, pubkey: nostrUser.pubkey, - prvkey: nostrUser.prvkey } } catch (error) { - console.warn('Failed to fetch Nostr keys, returning basic user info:', error) - // Return basic user info without Nostr keys if the endpoint fails + console.warn('Failed to fetch Nostr pubkey from /auth/nostr/me, returning basic user info:', error) return basicUser } } 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/composables/useActivityDetail.ts b/src/modules/activities/composables/useActivityDetail.ts index e29a272..9fc3ce4 100644 --- a/src/modules/activities/composables/useActivityDetail.ts +++ b/src/modules/activities/composables/useActivityDetail.ts @@ -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 diff --git a/src/modules/activities/services/ActivitiesNostrService.ts b/src/modules/activities/services/ActivitiesNostrService.ts index f098728..4883910 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, @@ -25,10 +23,20 @@ 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[] } /** - * 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 +113,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. */ @@ -168,6 +150,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] } @@ -179,11 +162,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 -} diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index 4cc9523..3e728c7 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -180,17 +180,14 @@ export class AuthService extends BaseService { this.isLoading.value = true const updatedUser = await this.lnbitsAPI.updateProfile(data) - // Preserve prvkey and pubkey from existing user since /auth/update doesn't return them + // Preserve pubkey from existing user since /auth/update doesn't return it. + // Kind-0 metadata is published server-side by lnbits's PATCH /auth handler + // (aiolabs/lnbits commit 869f67c3); no webapp-side broadcast path remains. this.user.value = { ...updatedUser, pubkey: this.user.value?.pubkey || updatedUser.pubkey, - 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. - } catch (error) { const err = this.handleError(error, 'updateProfile') throw err diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index 85a91c4..ab75da0 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -18,7 +18,7 @@ import ThreadedPost from './ThreadedPost.vue' import ScheduledEventCard from './ScheduledEventCard.vue' import appConfig from '@/app.config' import type { ContentFilter, FeedPost } from '../services/FeedService' -import type { ScheduledEvent } from '../services/ScheduledEventService' +import type { ScheduledEvent } from '@/modules/tasks/services/TaskService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service' import type { RelayHub } from '@/modules/base/nostr/relay-hub' diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue index 46c188e..20bf261 100644 --- a/src/modules/nostr-feed/components/ScheduledEventCard.vue +++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue @@ -17,7 +17,7 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible' import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' -import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' +import type { ScheduledEvent, EventCompletion, TaskStatus } from '@/modules/tasks/services/TaskService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { AuthService } from '@/modules/base/auth/auth-service'