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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-03 16:16:39 +02:00
commit 7ef3cb33cc
2 changed files with 101 additions and 34 deletions

View file

@ -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<string>) {
const transport = injectService<NostrTransportService>(
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
)
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth()
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref<string>) {
async function refreshStats(): Promise<void> {
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<string>) {
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<Record<string, unknown>>(
'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<string, unknown>,
}
} 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,

View file

@ -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<ActivityTicket> {
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",