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 { ref, onMounted, type Ref } from 'vue'
import { useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' 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' export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
@ -39,21 +40,26 @@ export interface EventStats {
/** /**
* Stateful scanner driver. Owns the camera lifecycle (delegated to * Stateful scanner driver. Owns the camera lifecycle (delegated to
* useQRScanner upstream), the QR decode, the * useQRScanner upstream), the QR decode, the register-ticket call,
* `events_ticket_register` RPC call, an authoritative event roster * an authoritative event roster fetch, and a session-local dedup
* fetched from `events_list_event_tickets`, and a session-local * cache.
* dedup cache.
* *
* Counts + the displayed scanned list come from the backend so the * Counts + the displayed scanned list come from the backend so the
* UI agrees with reality even when a second organizer is scanning * UI agrees with reality even when a second organizer is scanning
* on another device. The localStorage cache is kept as a silent * 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 * dedup so the 5-fps decode loop doesn't re-fire the request on a
* the camera held in frame for multiple ticks. * 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>) { export function useTicketScanner(activityId: Ref<string>) {
const transport = injectService<NostrTransportService>( const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, const { currentUser } = useAuth()
)
const isProcessing = ref(false) const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null) const lastScan = ref<ScanResult | null>(null)
@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref<string>) {
async function refreshStats(): Promise<void> { async function refreshStats(): Promise<void> {
if (!activityId.value) return if (!activityId.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
statsError.value = 'No wallet admin key available'
return
}
statsLoading.value = true statsLoading.value = true
statsError.value = null statsError.value = null
try { try {
const data = await transport.call<{ const data = await ticketApi.getEventStats(activityId.value, adminKey)
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 })
eventStats.value = { eventStats.value = {
sold: data.sold, sold: data.sold,
registered: data.registered, registered: data.registered,
@ -132,26 +133,38 @@ export function useTicketScanner(activityId: Ref<string>) {
return 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 isProcessing.value = true
try { try {
const ticket = await transport.call<Record<string, unknown>>( const ticket = await ticketApi.registerTicket(ticketId, adminKey)
'events_ticket_register',
{
event_id: activityId.value,
ticket_id: ticketId,
},
)
const name = (ticket?.name as string | null | undefined) ?? null
scanned.value = [ scanned.value = [
{ ticketId, name, registeredAt: new Date().toISOString() }, {
ticketId,
name: ticket.name ?? null,
registeredAt: new Date().toISOString(),
},
...scanned.value, ...scanned.value,
] ]
lastScan.value = { status: 'ok', ticketId, ticket } lastScan.value = {
status: 'ok',
ticketId,
ticket: ticket as unknown as Record<string, unknown>,
}
} catch (e) { } catch (e) {
// Backend RPC errors arrive as NostrRpcError with the // Backend errors surface via TicketApiService.request as the
// string in `.message`: "Ticket not paid for", "Ticket // HTTP `detail` string: "Ticket not paid for", "Ticket
// already registered", "Ticket does not exist on this // already registered", "Ticket does not exist.", "You do not
// event", "You do not own this event", etc. // own this event.", etc.
lastScan.value = { lastScan.value = {
status: 'error', status: 'error',
ticketId, 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 * Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin", * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",