Compare commits

...

3 commits

Author SHA1 Message Date
1a0738576a chore(api): remove User.prvkey field + thread-through helpers (Q1.2 Option b)
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) <noreply@anthropic.com>
2026-05-30 08:04:05 +02:00
141e59da82 chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent
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) <noreply@anthropic.com>
2026-05-30 08:04:01 +02:00
cb6e1351fb fix(activities): scope detail-page query by NIP-52 d-tag
`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) <noreply@anthropic.com>
2026-05-30 08:03:55 +02:00
7 changed files with 87 additions and 95 deletions

View file

@ -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<User> {
// First get basic user info from /auth
const basicUser = await this.request<User>('/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<User>('/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
}
}

View file

@ -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<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
const ticketApi = tryInjectService<TicketApiService>(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<CalendarTimeEvent> = {
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
}

View file

@ -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

View file

@ -1,12 +1,10 @@
import { BaseService } from '@/core/base/BaseService'
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import {
NIP52_KINDS,
parseCalendarTimeEvent,
parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52'
import {
calendarTimeEventToActivity,
@ -25,10 +23,20 @@ export interface CalendarEventFilters {
hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */
geohash?: string
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
dTags?: string[]
}
/**
* Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub.
* Service for subscribing to NIP-52 Calendar Events via RelayHub.
*
* Publishing kind-31922 calendar events lives server-side in the
* `aiolabs/events` LNbits extension (signer-abstraction branch, commit
* 66076d6) `POST /events/api/v1/events` constructs and signs the
* event via NostrSigner and broadcasts it to the operator's configured
* relays. The webapp constructs only the request payload; see
* CreateActivityDialog for the flow.
*
* Extends BaseService for standardized dependency injection and lifecycle.
*/
export class ActivitiesNostrService extends BaseService {
@ -105,32 +113,6 @@ export class ActivitiesNostrService extends BaseService {
return activities
}
/**
* Publish a NIP-52 time-based calendar event.
* Requires an authenticated user with a signing key.
*/
async publishCalendarEvent(
eventData: Partial<CalendarTimeEvent>,
signingKeyHex: string
): Promise<{ success: number; total: number }> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const tags = buildCalendarTimeEventTags(eventData)
const template: EventTemplate = {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
created_at: Math.floor(Date.now() / 1000),
content: eventData.content ?? '',
tags,
}
const privkeyBytes = hexToUint8Array(signingKeyHex)
const signedEvent = finalizeEvent(template, privkeyBytes)
return await this.relayHub.publishEvent(signedEvent)
}
/**
* Parse a raw Nostr event into an Activity view model.
*/
@ -168,6 +150,7 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter]
}
@ -179,11 +162,3 @@ export class ActivitiesNostrService extends BaseService {
this.activeUnsubscribes = []
}
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -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

View file

@ -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'

View file

@ -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'