From 141e59da82e0a5737a417a60f8d06f8c3c9f3b04 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 1/4] 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 -} From 1a0738576a9139bfb634e0b5d5660d4f40aac47d Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 22:16:52 +0200 Subject: [PATCH 2/4] chore(api): remove User.prvkey field + thread-through helpers (Q1.2 Option b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic phase-1 final per design-questions Q1.2 Option (b) and the 2026-05-29T00:30Z architecture-decisions lock-in. Removing the prvkey?: string field from the User interface flips the type system into the pressure mechanism that forces phase-2 to start: every remaining bucket-B sign-site (chat / forum / nostr-feed / activities-bookmarks/RSVP / market / tasks) now fails vue-tsc until it migrates to signEventViaLnbits() against POST /api/v1/auth/sign-event (aiolabs/lnbits PR #29, deployed on aio-demo). Changes: - src/lib/api/lnbits.ts: - Drop `prvkey?: string` from User interface. - getCurrentUser(): /auth/nostr/me used to merge prvkey alongside pubkey; post-cascade the endpoint returns only the pubkey. Updated the comment + cleaned the merge object. - src/modules/base/auth/auth-service.ts: - updateProfile() no longer threads `prvkey` through the merge. Server-side PATCH /auth publishes kind-0 via the signer per 869f67c3; the webapp doesn't keep prvkey at all. - src/modules/nostr-feed/components/NostrFeed.vue + src/modules/nostr-feed/components/ScheduledEventCard.vue: - Repoint the `ScheduledEvent` type import from the deleted `../services/ScheduledEventService` to `@/modules/tasks/services/TaskService`. Trivial post-#81-merge cleanup that fell through the dedup PR; same file exports the same interface. vue-tsc --noEmit fails with 8 errors after this commit, all TS2339 "Property 'prvkey' does not exist". The failing sites are exactly the bucket-B targets the design doc enumerates as phase-2 migration work: | Failing site | Bucket B kind | |-----------------------------------------------|---------------| | activities/composables/useBookmarks.ts:92,113 | kind 10003 (NIP-51 bookmarks) | | activities/composables/useRSVP.ts:153,187 | kind 31925 (NIP-52 RSVP) | | base/services/NostrTransportService.ts:100,112| kind 21000 (NIP-44 v2 RPC envelope) | | market/composables/useMarket.ts:455 | NIP-44 gift-wrap (kind 1059) unwrap | | nostr-feed/components/NostrFeed.vue:408 | kind 5 (deletion of own post) | NOT caught by vue-tsc but still bucket-B (BaseService injection pattern types `this.authService` as `any`, so optional chaining bypasses the type check): - chat/services/chat-service.ts:341,511,714 - forum/services/SubmissionService.ts:755,1167 - nostr-feed/services/SubmissionService.ts:769,1226 - nostr-feed/components/NoteComposer.vue:306 - nostr-feed/components/RideshareComposer.vue:423 - tasks/services/TaskService.ts:507,562,616 Those sites will runtime-fail (prvkey is undefined from the API post-cascade) but won't surface at compile time. Phase 2's per-module migration (Q5.2) catches them as each module flips. The webapp WILL NOT BUILD CLEANLY after this PR merges to dev until phase 2 lands. That's the intended trade-off per Q5.1 + Q1.2; the broken-build interval is the design-intended pressure mechanism to start phase 2. server-deploy's webapp-demo flake.lock bump will fail until phase 2 lands; demo will stay on the pre-PR-#84 webapp during that interval. Refs: - log:2026-05-29T00:30Z (consolidated decisions; Q1.2 Option (b) + Q5.1 risk: demo gap acceptable) - log:2026-05-29T17:30Z (lnbits confirming this PR stays atomic-after-the-two-bucket-A PRs) - ~/dev/coordination/webapp-design-questions.md Q1.2 + Q5.1 - Parent initiative: aiolabs/lnbits#9 (signer abstraction / bunker) - Sibling PRs (stacked base→head): #82 → #83 → this Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/api/lnbits.ts | 24 ++++++++++++------- src/modules/base/auth/auth-service.ts | 9 +++---- .../nostr-feed/components/NostrFeed.vue | 2 +- .../components/ScheduledEventCard.vue | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) 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/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' From 9bef2d58acd580676a892c8cc3df4e6a02aae3af Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 3/4] 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 -} From 9a300c16798cfa5fe7f76e603205597d6917e252 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 22:16:52 +0200 Subject: [PATCH 4/4] chore(api): remove User.prvkey field + thread-through helpers (Q1.2 Option b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic phase-1 final per design-questions Q1.2 Option (b) and the 2026-05-29T00:30Z architecture-decisions lock-in. Removing the prvkey?: string field from the User interface flips the type system into the pressure mechanism that forces phase-2 to start: every remaining bucket-B sign-site (chat / forum / nostr-feed / activities-bookmarks/RSVP / market / tasks) now fails vue-tsc until it migrates to signEventViaLnbits() against POST /api/v1/auth/sign-event (aiolabs/lnbits PR #29, deployed on aio-demo). Changes: - src/lib/api/lnbits.ts: - Drop `prvkey?: string` from User interface. - getCurrentUser(): /auth/nostr/me used to merge prvkey alongside pubkey; post-cascade the endpoint returns only the pubkey. Updated the comment + cleaned the merge object. - src/modules/base/auth/auth-service.ts: - updateProfile() no longer threads `prvkey` through the merge. Server-side PATCH /auth publishes kind-0 via the signer per 869f67c3; the webapp doesn't keep prvkey at all. - src/modules/nostr-feed/components/NostrFeed.vue + src/modules/nostr-feed/components/ScheduledEventCard.vue: - Repoint the `ScheduledEvent` type import from the deleted `../services/ScheduledEventService` to `@/modules/tasks/services/TaskService`. Trivial post-#81-merge cleanup that fell through the dedup PR; same file exports the same interface. vue-tsc --noEmit fails with 8 errors after this commit, all TS2339 "Property 'prvkey' does not exist". The failing sites are exactly the bucket-B targets the design doc enumerates as phase-2 migration work: | Failing site | Bucket B kind | |-----------------------------------------------|---------------| | activities/composables/useBookmarks.ts:92,113 | kind 10003 (NIP-51 bookmarks) | | activities/composables/useRSVP.ts:153,187 | kind 31925 (NIP-52 RSVP) | | base/services/NostrTransportService.ts:100,112| kind 21000 (NIP-44 v2 RPC envelope) | | market/composables/useMarket.ts:455 | NIP-44 gift-wrap (kind 1059) unwrap | | nostr-feed/components/NostrFeed.vue:408 | kind 5 (deletion of own post) | NOT caught by vue-tsc but still bucket-B (BaseService injection pattern types `this.authService` as `any`, so optional chaining bypasses the type check): - chat/services/chat-service.ts:341,511,714 - forum/services/SubmissionService.ts:755,1167 - nostr-feed/services/SubmissionService.ts:769,1226 - nostr-feed/components/NoteComposer.vue:306 - nostr-feed/components/RideshareComposer.vue:423 - tasks/services/TaskService.ts:507,562,616 Those sites will runtime-fail (prvkey is undefined from the API post-cascade) but won't surface at compile time. Phase 2's per-module migration (Q5.2) catches them as each module flips. The webapp WILL NOT BUILD CLEANLY after this PR merges to dev until phase 2 lands. That's the intended trade-off per Q5.1 + Q1.2; the broken-build interval is the design-intended pressure mechanism to start phase 2. server-deploy's webapp-demo flake.lock bump will fail until phase 2 lands; demo will stay on the pre-PR-#84 webapp during that interval. Refs: - log:2026-05-29T00:30Z (consolidated decisions; Q1.2 Option (b) + Q5.1 risk: demo gap acceptable) - log:2026-05-29T17:30Z (lnbits confirming this PR stays atomic-after-the-two-bucket-A PRs) - ~/dev/coordination/webapp-design-questions.md Q1.2 + Q5.1 - Parent initiative: aiolabs/lnbits#9 (signer abstraction / bunker) - Sibling PRs (stacked base→head): #82 → #83 → this Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/api/lnbits.ts | 24 ++++++++++++------- src/modules/base/auth/auth-service.ts | 9 +++---- .../nostr-feed/components/NostrFeed.vue | 2 +- .../components/ScheduledEventCard.vue | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) 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/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'