From ba916a4c37ddce85778d1da30be5c4c18680a14c Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 1/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 cb6e1351fb6299de46b5bf8b622d72ae0dd4dd5c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 08:03:55 +0200 Subject: [PATCH 2/3] fix(activities): scope detail-page query by NIP-52 d-tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../activities/composables/useActivityDetail.ts | 14 ++++++++++---- .../activities/services/ActivitiesNostrService.ts | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) 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..da5ed05 100644 --- a/src/modules/activities/services/ActivitiesNostrService.ts +++ b/src/modules/activities/services/ActivitiesNostrService.ts @@ -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] } From 141e59da82e0a5737a417a60f8d06f8c3c9f3b04 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 da5ed05..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, @@ -30,7 +28,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 { @@ -107,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. */ @@ -182,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 -}