fix(activities): route ticket scanner through HTTP, not nostr-transport RPC #87
2 changed files with 101 additions and 34 deletions
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>
commit
7ef3cb33cc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue