From ba916a4c37ddce85778d1da30be5c4c18680a14c Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 21:43:35 +0200 Subject: [PATCH 1/5] 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 be182cff3f68aa1ba5e2aa4615a3cc77fbe23229 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 29 May 2026 22:16:52 +0200 Subject: [PATCH 2/5] 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 cb6e1351fb6299de46b5bf8b622d72ae0dd4dd5c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 08:03:55 +0200 Subject: [PATCH 3/5] 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 4/5] 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 5/5] 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'