Compare commits

...

3 commits

Author SHA1 Message Date
be182cff3f 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 07:50:15 +02:00
ba916a4c37 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 07:50:01 +02:00
261eded316 fix(api): align webapp client with post-cascade lnbits + surface error detail
Two fixes that together make the kind-0 server-side publish path (this
PR's whole reason for existing) actually work end-to-end against the
deployed cascade:

1. **updateProfile() uses PATCH /api/v1/auth, not PUT /auth/update.**
   aiolabs/lnbits PR #26 gap-fill (869f67c3) wired
   _publish_nostr_metadata_event into the PATCH handler at
   auth_api.py:546. The legacy `/auth/update` route doesn't exist on
   the post-cascade server — a `PUT /auth/update` request gets routed
   into the `/auth/{provider}` SSO wildcard which only allows GET and
   returns 405. Caught while smoke-testing this PR against a local
   regtest pointed at the issue-18-phase-2.3 branch.

2. **request<T>() parses FastAPI's `{"detail": "..."}` error shape.**
   The old error path threw `API request failed: 405 Method Not Allowed`
   for the regtest's 405 above — useful only if you also opened the
   network panel and read the response body manually. Now we parse the
   detail (string or pydantic-validation array), include the endpoint
   path, and throw `LNbits /auth 405: Method Not Allowed`. Falls back
   to raw text for non-JSON bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 07:45:35 +02:00
6 changed files with 99 additions and 95 deletions

View file

@ -40,8 +40,12 @@ interface User {
username?: string username?: string
email?: string email?: string
pubkey?: string pubkey?: string
// pragma: allowlist secret // The `prvkey` field was removed from this interface as the final step of
prvkey?: string // Nostr signing key for user // 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 external_id?: string
extensions: string[] extensions: string[]
wallets: Wallet[] wallets: Wallet[]
@ -112,12 +116,29 @@ export class LnbitsAPI extends BaseService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
// Try to surface FastAPI's `{"detail": "..."}` shape; fall back to raw
// body for non-JSON errors. Without this, every backend error renders
// as a generic "API request failed: <status>" and you can't distinguish
// "wrong endpoint" from "expired token" from "validation failure".
let detail: string = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed && typeof parsed.detail === 'string') {
detail = parsed.detail
} else if (parsed && Array.isArray(parsed.detail)) {
// pydantic ValidationError: take the first msg
detail = parsed.detail[0]?.msg ?? errorText
}
} catch {
// body wasn't JSON; keep the raw text in `detail`
}
console.error('LNBits API Error:', { console.error('LNBits API Error:', {
endpoint,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
errorText detail,
}) })
throw new Error(`API request failed: ${response.status} ${response.statusText}`) throw new Error(`LNbits ${endpoint} ${response.status}: ${detail || response.statusText}`)
} }
const data = await response.json() const data = await response.json()
@ -157,20 +178,22 @@ export class LnbitsAPI extends BaseService {
async getCurrentUser(): Promise<User> { async getCurrentUser(): Promise<User> {
// First get basic user info from /auth // First get basic user info from /auth
const basicUser = await this.request<User>('/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 { try {
const nostrUser = await this.request<User>('/auth/nostr/me') const nostrUser = await this.request<User>('/auth/nostr/me')
// Merge the data - basic user info + Nostr keys
return { return {
...basicUser, ...basicUser,
pubkey: nostrUser.pubkey, pubkey: nostrUser.pubkey,
prvkey: nostrUser.prvkey
} }
} catch (error) { } catch (error) {
console.warn('Failed to fetch Nostr keys, returning basic user info:', error) console.warn('Failed to fetch Nostr pubkey from /auth/nostr/me, returning basic user info:', error)
// Return basic user info without Nostr keys if the endpoint fails
return basicUser return basicUser
} }
} }
@ -186,8 +209,12 @@ export class LnbitsAPI extends BaseService {
} }
async updateProfile(data: Partial<User>): Promise<User> { async updateProfile(data: Partial<User>): Promise<User> {
return this.request<User>('/auth/update', { // aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired
method: 'PUT', // _publish_nostr_metadata_event into PATCH /api/v1/auth
// (auth_api.py:546). The legacy PUT /auth/update route does not
// exist on the post-cascade server.
return this.request<User>('/auth', {
method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }

View file

@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next' import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' import type { TicketApiService } from '../services/TicketApiService'
import type { CalendarTimeEvent } from '../types/nip52' import type { CreateEventRequest } from '../types/ticket'
import type { ActivityCategory } from '../types/category' import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue' import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue' import LocationPicker from './LocationPicker.vue'
@ -67,56 +67,64 @@ const form = useForm({
const isFormValid = computed(() => form.meta.value.valid) const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
if (!nostrService) { if (!ticketApi) {
toast.error('Activities service not available') toast.error('Activities service not available')
return return
} }
const signingKey = currentUser.value?.prvkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!signingKey) { if (!invoiceKey) {
toast.error('Signing key not available. Please log in again.') toast.error('No wallet available. Please log in first.')
return return
} }
isPublishing.value = true isPublishing.value = true
try { try {
// Build unix timestamps // Compose ISO 8601 datetime strings the events extension parses.
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) const startIso = `${values.startDate}T${values.startTime}`
let endTimestamp: number | undefined const endIso =
if (values.endDate && values.endTime) { values.endDate && values.endTime
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) ? `${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 await ticketApi.createEvent(eventData, invoiceKey)
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const eventData: Partial<CalendarTimeEvent> = { // Approval workflow caveat: non-admin users on instances with
dTag, // `auto_approve=false` (the default) land in the proposal queue;
title: values.title, // their event isn't published to relays until an admin approves.
summary: values.summary || undefined, // Admins-and-auto-approve-on instances publish immediately.
content: values.description, toast.success('Activity created!')
image: values.image || undefined, emit('created')
start: startTimestamp, handleClose()
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')
}
} catch (err) { } catch (err) {
console.error('Failed to publish activity:', err) console.error('Failed to create activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to publish activity') toast.error(err instanceof Error ? err.message : 'Failed to create activity')
} finally { } finally {
isPublishing.value = false isPublishing.value = false
} }

View file

@ -1,12 +1,10 @@
import { BaseService } from '@/core/base/BaseService' 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 type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import { import {
NIP52_KINDS, NIP52_KINDS,
parseCalendarTimeEvent, parseCalendarTimeEvent,
parseCalendarDateEvent, parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52' } from '../types/nip52'
import { import {
calendarTimeEventToActivity, 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. * Extends BaseService for standardized dependency injection and lifecycle.
*/ */
export class ActivitiesNostrService extends BaseService { export class ActivitiesNostrService extends BaseService {
@ -105,32 +111,6 @@ export class ActivitiesNostrService extends BaseService {
return activities 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. * Parse a raw Nostr event into an Activity view model.
*/ */
@ -179,11 +159,3 @@ export class ActivitiesNostrService extends BaseService {
this.activeUnsubscribes = [] 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 this.isLoading.value = true
const updatedUser = await this.lnbitsAPI.updateProfile(data) 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 = { this.user.value = {
...updatedUser, ...updatedUser,
pubkey: this.user.value?.pubkey || updatedUser.pubkey, 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) { } catch (error) {
const err = this.handleError(error, 'updateProfile') const err = this.handleError(error, 'updateProfile')
throw err throw err

View file

@ -18,7 +18,7 @@ import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue' import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config' import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService' 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub' import type { RelayHub } from '@/modules/base/nostr/relay-hub'

View file

@ -17,7 +17,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'