From 7ef3cb33cc8f12c30f3f1cbd2c24ba5b7040dea8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 3 Jun 2026 16:16:39 +0200 Subject: [PATCH] fix(activities): route ticket scanner through HTTP, not nostr-transport RPC Post-aiolabs/lnbits#9 the webapp no longer holds a raw user prvkey, so the kind-21000 nostr-transport RPC layer fails closed for every caller at the "Sign-in with a Nostr key required to call RPC" guard in NostrTransportService.call. The ticket scanner was the only remaining user of that transport on the organizer side. Route the door scanner through the events extension's existing admin_key-gated HTTP endpoints instead, matching the Bucket A pattern the team converged on for the rest of the prvkey-removal migration (operator-class events route through extension HTTP, not webapp-side signing). Pairs with a new GET /tickets/event/{id}/stats endpoint on the events extension (admin_key + owner check, mirroring the events_list_event_tickets RPC shape). PUT /tickets/register/{id} was already hardened in v1.6.1-aio.3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useTicketScanner.ts | 83 +++++++++++-------- .../activities/services/TicketApiService.ts | 54 ++++++++++++ 2 files changed, 102 insertions(+), 35 deletions(-) diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts index 7053f5e..4538d3e 100644 --- a/src/modules/activities/composables/useTicketScanner.ts +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -1,7 +1,8 @@ import { ref, onMounted, type Ref } from 'vue' import { useLocalStorage } from '@vueuse/core' import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import type { NostrTransportService } from '@/modules/base/services/NostrTransportService' +import { useAuth } from '@/composables/useAuthService' +import type { TicketApiService } from '../services/TicketApiService' export type ScanStatus = 'ok' | 'duplicate-session' | 'error' @@ -39,21 +40,26 @@ export interface EventStats { /** * Stateful scanner driver. Owns the camera lifecycle (delegated to - * useQRScanner upstream), the QR decode, the - * `events_ticket_register` RPC call, an authoritative event roster - * fetched from `events_list_event_tickets`, and a session-local - * dedup cache. + * useQRScanner upstream), the QR decode, the register-ticket call, + * an authoritative event roster fetch, and a session-local dedup + * cache. * * Counts + the displayed scanned list come from the backend so the * UI agrees with reality even when a second organizer is scanning * on another device. The localStorage cache is kept as a silent - * dedup so the 5-fps decode loop doesn't re-fire the RPC on a QR - * the camera held in frame for multiple ticks. + * dedup so the 5-fps decode loop doesn't re-fire the request on a + * QR the camera held in frame for multiple ticks. + * + * Auth: the organizer's wallet admin_key. The events extension's + * `GET /tickets/event/{id}/stats` + `PUT /tickets/register/{id}` + * endpoints both check the event's wallet is in the caller's + * wallet set, so admin_key alone is sufficient. We deliberately + * route via HTTP rather than the kind-21000 nostr-transport RPC + * because post-#9 the webapp no longer holds a raw user prvkey. */ export function useTicketScanner(activityId: Ref) { - const transport = injectService( - SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, - ) + const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) + const { currentUser } = useAuth() const isProcessing = ref(false) const lastScan = ref(null) @@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref) { async function refreshStats(): Promise { if (!activityId.value) return + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) { + statsError.value = 'No wallet admin key available' + return + } statsLoading.value = true statsError.value = null try { - const data = await transport.call<{ - sold: number - registered: number - remaining: number - tickets: Array<{ - id: string - name?: string | null - registered: boolean - registered_at: string | null - }> - }>('events_list_event_tickets', { event_id: activityId.value }) + const data = await ticketApi.getEventStats(activityId.value, adminKey) eventStats.value = { sold: data.sold, registered: data.registered, @@ -132,26 +133,38 @@ export function useTicketScanner(activityId: Ref) { return } + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) { + lastScan.value = { + status: 'error', + ticketId, + message: 'No wallet admin key available', + } + isPaused.value = true + return + } + isProcessing.value = true try { - const ticket = await transport.call>( - 'events_ticket_register', - { - event_id: activityId.value, - ticket_id: ticketId, - }, - ) - const name = (ticket?.name as string | null | undefined) ?? null + const ticket = await ticketApi.registerTicket(ticketId, adminKey) scanned.value = [ - { ticketId, name, registeredAt: new Date().toISOString() }, + { + ticketId, + name: ticket.name ?? null, + registeredAt: new Date().toISOString(), + }, ...scanned.value, ] - lastScan.value = { status: 'ok', ticketId, ticket } + lastScan.value = { + status: 'ok', + ticketId, + ticket: ticket as unknown as Record, + } } catch (e) { - // Backend RPC errors arrive as NostrRpcError with the - // string in `.message`: "Ticket not paid for", "Ticket - // already registered", "Ticket does not exist on this - // event", "You do not own this event", etc. + // Backend errors surface via TicketApiService.request as the + // HTTP `detail` string: "Ticket not paid for", "Ticket + // already registered", "Ticket does not exist.", "You do not + // own this event.", etc. lastScan.value = { status: 'error', ticketId, diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index f127d5e..43c2308 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -252,6 +252,60 @@ export class TicketApiService { } } + /** + * Door-scanner roster + counts for one event. Organizer-only — + * requires the event-owning wallet's admin_key. Returns the same + * shape the `events_list_event_tickets` nostr-transport RPC does; + * we route via HTTP because post-#9 the webapp no longer holds a + * raw user prvkey to sign kind-21000 envelopes with. + */ + async getEventStats( + eventId: string, + adminKey: string, + ): Promise<{ + event_id: string + sold: number + registered: number + remaining: number + tickets: Array<{ + id: string + name?: string | null + registered: boolean + registered_at: string | null + }> + }> { + return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { + method: 'GET', + headers: { 'X-API-KEY': adminKey }, + }) + } + + /** + * Mark a paid ticket as registered at the door. Organizer-only — + * requires the event-owning wallet's admin_key. Backend rejects + * unpaid / already-registered / not-owned cases with HTTP errors + * whose `detail` becomes the thrown Error message. + */ + async registerTicket(ticketId: string, adminKey: string): Promise { + const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, { + method: 'PUT', + headers: { 'X-API-KEY': adminKey }, + }) + return { + id: t.id, + wallet: t.wallet, + activityId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + extra: t.extra as ActivityTicketExtra | undefined, + } + } + /** * Probe whether the current user has LNbits admin privileges. The * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",