From 531de18ad764bc475a34d4d22180fd11a5d1f094 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 261eded3166e80289647c957aec6823a7da41c33 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 07:45:35 +0200 Subject: [PATCH 2/3] fix(api): align webapp client with post-cascade lnbits + surface error detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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) --- src/lib/api/lnbits.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index 1e1ecc9..bdd2e18 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -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: " 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): Promise { - return this.request('/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('/auth', { + method: 'PATCH', body: JSON.stringify(data), }) } From ba916a4c37ddce85778d1da30be5c4c18680a14c 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 -}