Compare commits
No commits in common. "0f8f98d4c57b7fb8b02201b878114a47276e8c6b" and "f3c8b1cf95bebf10e1ddd891d42ae9700c29c399" have entirely different histories.
0f8f98d4c5
...
f3c8b1cf95
9 changed files with 1 additions and 607 deletions
|
|
@ -11,13 +11,6 @@ VITE_API_KEY=your-api-key-here
|
||||||
VITE_LNBITS_DEBUG=false
|
VITE_LNBITS_DEBUG=false
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
VITE_WEBSOCKET_ENABLED=true
|
||||||
|
|
||||||
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
|
|
||||||
# Logged by the LNbits server at startup:
|
|
||||||
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
|
|
||||||
# Required for the activities ticket scanner; legacy HTTP path still
|
|
||||||
# works without it.
|
|
||||||
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
|
|
||||||
|
|
||||||
# Lightning Address Domain (optional)
|
# Lightning Address Domain (optional)
|
||||||
# Override the domain used for Lightning Addresses
|
# Override the domain used for Lightning Addresses
|
||||||
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
||||||
|
|
|
||||||
|
|
@ -149,9 +149,6 @@ export const SERVICE_TOKENS = {
|
||||||
// Nostr metadata services
|
// Nostr metadata services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||||
|
|
||||||
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
|
|
||||||
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
|
|
||||||
|
|
||||||
// Activities services (Nostr-native events + ticketing module)
|
// Activities services (Nostr-native events + ticketing module)
|
||||||
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||||
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ export const LNBITS_CONFIG = {
|
||||||
// This should point to your LNBits instance
|
// This should point to your LNBits instance
|
||||||
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
|
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
|
||||||
|
|
||||||
// LNbits Nostr-transport server pubkey. The webapp encrypts its
|
|
||||||
// signed kind-21000 RPC events to this pubkey and listens for
|
|
||||||
// signed responses from it. Logged by the LNbits server at startup
|
|
||||||
// (`Nostr transport: starting with pubkey <hex>...`).
|
|
||||||
NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '',
|
|
||||||
|
|
||||||
// Whether to enable debug logging
|
// Whether to enable debug logging
|
||||||
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { ref, 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'
|
|
||||||
|
|
||||||
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
|
|
||||||
|
|
||||||
export interface ScanResult {
|
|
||||||
status: ScanStatus
|
|
||||||
ticketId: string
|
|
||||||
/** Backend response payload on OK. */
|
|
||||||
ticket?: Record<string, unknown>
|
|
||||||
/** Error string from the backend or local validation. */
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScanRecord {
|
|
||||||
ticketId: string
|
|
||||||
/** Holder display name from the backend, if any. */
|
|
||||||
name?: string | null
|
|
||||||
registeredAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
|
||||||
* useQRScanner upstream), the QR decode, the
|
|
||||||
* `events_ticket_register` RPC call, and a session-local scanned
|
|
||||||
* list persisted to localStorage so a page refresh doesn't ask the
|
|
||||||
* organizer to rescan tickets they already counted.
|
|
||||||
*
|
|
||||||
* Mirrors the LNbits admin Quasar register page's
|
|
||||||
* `events_scanned_<eventId>` localStorage key with the
|
|
||||||
* `activities_scanned_<id>` prefix.
|
|
||||||
*/
|
|
||||||
export function useTicketScanner(activityId: Ref<string>) {
|
|
||||||
const transport = injectService<NostrTransportService>(
|
|
||||||
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
|
|
||||||
)
|
|
||||||
|
|
||||||
const isProcessing = ref(false)
|
|
||||||
const lastScan = ref<ScanResult | null>(null)
|
|
||||||
const scanned = useLocalStorage<ScanRecord[]>(
|
|
||||||
() => `activities_scanned_${activityId.value}`,
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
function parseTicketId(qrText: string): string {
|
|
||||||
return qrText.startsWith('ticket://')
|
|
||||||
? qrText.slice('ticket://'.length)
|
|
||||||
: qrText
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDecode(qrText: string): Promise<void> {
|
|
||||||
if (isProcessing.value) return
|
|
||||||
const ticketId = parseTicketId(qrText).trim()
|
|
||||||
if (!ticketId) return
|
|
||||||
|
|
||||||
// Session-local de-dup. Distinct from the backend's "already
|
|
||||||
// registered" — this guards against the QR being held in front
|
|
||||||
// of the camera for multiple decode frames.
|
|
||||||
if (scanned.value.some(r => r.ticketId === ticketId)) {
|
|
||||||
lastScan.value = { status: 'duplicate-session', ticketId }
|
|
||||||
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
|
|
||||||
scanned.value = [
|
|
||||||
{ ticketId, name, registeredAt: new Date().toISOString() },
|
|
||||||
...scanned.value,
|
|
||||||
]
|
|
||||||
lastScan.value = { status: 'ok', ticketId, ticket }
|
|
||||||
} 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.
|
|
||||||
lastScan.value = {
|
|
||||||
status: 'error',
|
|
||||||
ticketId,
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearScanned() {
|
|
||||||
scanned.value = []
|
|
||||||
lastScan.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissLastScan() {
|
|
||||||
lastScan.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isProcessing,
|
|
||||||
lastScan,
|
|
||||||
scanned,
|
|
||||||
onDecode,
|
|
||||||
clearScanned,
|
|
||||||
dismissLastScan,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -78,15 +78,6 @@ export const activitiesModule = createModulePlugin({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/scan/:activityId',
|
|
||||||
name: 'scan-tickets',
|
|
||||||
component: () => import('./views/ScanTicketsPage.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Scan Tickets',
|
|
||||||
requiresAuth: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/events',
|
path: '/events',
|
||||||
name: 'events',
|
name: 'events',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
|
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
|
|
@ -64,10 +64,6 @@ function openEditDialog() {
|
||||||
activitiesStore.showCreateDialog = true
|
activitiesStore.showCreateDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openScannerPage() {
|
|
||||||
router.push({ name: 'scan-tickets', params: { activityId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
if (!activity.value) return ''
|
if (!activity.value) return ''
|
||||||
const a = activity.value
|
const a = activity.value
|
||||||
|
|
@ -161,17 +157,6 @@ function goToMyTickets() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Button
|
|
||||||
v-if="ownedLnbitsEvent"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5"
|
|
||||||
@click="openScannerPage"
|
|
||||||
aria-label="Scan tickets"
|
|
||||||
>
|
|
||||||
<ScanLine class="w-4 h-4" />
|
|
||||||
Scan
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="ownedLnbitsEvent"
|
v-if="ownedLnbitsEvent"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2 } from 'lucide-vue-next'
|
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const activityId = ref(route.params.activityId as string)
|
|
||||||
const { activity } = useActivityDetail(activityId.value)
|
|
||||||
|
|
||||||
const {
|
|
||||||
isProcessing,
|
|
||||||
lastScan,
|
|
||||||
scanned,
|
|
||||||
onDecode,
|
|
||||||
clearScanned,
|
|
||||||
dismissLastScan,
|
|
||||||
} = useTicketScanner(activityId)
|
|
||||||
|
|
||||||
const scannerOpen = ref(true)
|
|
||||||
|
|
||||||
const lastScanVariant = computed(() => {
|
|
||||||
switch (lastScan.value?.status) {
|
|
||||||
case 'ok':
|
|
||||||
return 'success'
|
|
||||||
case 'duplicate-session':
|
|
||||||
return 'warning'
|
|
||||||
case 'error':
|
|
||||||
return 'destructive'
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleResult(qrText: string) {
|
|
||||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
|
||||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
|
||||||
// ticket id at the session-list level.
|
|
||||||
void onDecode(qrText)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
if (window.history.length > 1) router.back()
|
|
||||||
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
|
||||||
try {
|
|
||||||
return format(new Date(iso), 'HH:mm:ss')
|
|
||||||
} catch {
|
|
||||||
return iso
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto py-6 px-4 max-w-2xl">
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
|
||||||
<ArrowLeft class="w-4 h-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Ticket class="w-4 h-4" />
|
|
||||||
{{ scanned.length }} scanned this session
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
|
||||||
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
|
|
||||||
{{ activity.title }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Scanner -->
|
|
||||||
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
|
|
||||||
<QRScanner @result="handleResult" @close="scannerOpen = false" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex justify-center my-6">
|
|
||||||
<Button @click="scannerOpen = true" class="gap-1.5">
|
|
||||||
<Ticket class="w-4 h-4" />
|
|
||||||
Resume scanning
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Last-scan banner — single line, dismissable. Keeps the
|
|
||||||
scanner viewport clean while still surfacing the result. -->
|
|
||||||
<div
|
|
||||||
v-if="lastScan"
|
|
||||||
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
|
|
||||||
:class="{
|
|
||||||
'bg-emerald-500/10 border-emerald-500/40': lastScanVariant === 'success',
|
|
||||||
'bg-amber-500/10 border-amber-500/40': lastScanVariant === 'warning',
|
|
||||||
'bg-destructive/10 border-destructive/40': lastScanVariant === 'destructive',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<CheckCircle2
|
|
||||||
v-if="lastScanVariant === 'success'"
|
|
||||||
class="w-5 h-5 text-emerald-500 shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<Clock
|
|
||||||
v-else-if="lastScanVariant === 'warning'"
|
|
||||||
class="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<AlertCircle
|
|
||||||
v-else
|
|
||||||
class="w-5 h-5 text-destructive shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-foreground">
|
|
||||||
<template v-if="lastScan.status === 'ok'">
|
|
||||||
Registered
|
|
||||||
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
|
|
||||||
— {{ lastScan.ticket.name }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="lastScan.status === 'duplicate-session'">
|
|
||||||
Already scanned in this session
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ lastScan.message || 'Scan failed' }}
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs font-mono text-muted-foreground break-all mt-0.5">
|
|
||||||
{{ lastScan.ticketId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" class="shrink-0" @click="dismissLastScan">
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing hint -->
|
|
||||||
<p
|
|
||||||
v-if="isProcessing"
|
|
||||||
class="text-xs text-center text-muted-foreground mt-3"
|
|
||||||
>
|
|
||||||
Sending registration over Nostr…
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Separator class="my-6" />
|
|
||||||
|
|
||||||
<!-- Scanned-this-session list. Persists to localStorage per
|
|
||||||
activity, mirroring the LNbits admin register page's
|
|
||||||
events_scanned_<eventId> pattern. -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-sm font-medium text-foreground">
|
|
||||||
Scanned ({{ scanned.length }})
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
v-if="scanned.length > 0"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5 text-xs"
|
|
||||||
@click="clearScanned"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
|
||||||
Clear list
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ScrollArea v-if="scanned.length > 0" class="h-72">
|
|
||||||
<ul class="space-y-1.5 pr-3">
|
|
||||||
<li
|
|
||||||
v-for="record in scanned"
|
|
||||||
:key="record.ticketId"
|
|
||||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
|
||||||
>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary" class="text-[10px] font-mono px-1.5">
|
|
||||||
{{ fmtTime(record.registeredAt) }}
|
|
||||||
</Badge>
|
|
||||||
<span v-if="record.name" class="font-medium text-foreground">
|
|
||||||
{{ record.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
|
||||||
{{ record.ticketId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ScrollArea>
|
|
||||||
<p v-else class="text-sm text-muted-foreground text-center py-6">
|
|
||||||
No tickets scanned yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { relayHub } from './nostr/relay-hub'
|
||||||
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
||||||
import { ProfileService } from './nostr/ProfileService'
|
import { ProfileService } from './nostr/ProfileService'
|
||||||
import { ReactionService } from './nostr/ReactionService'
|
import { ReactionService } from './nostr/ReactionService'
|
||||||
import { NostrTransportService } from './services/NostrTransportService'
|
|
||||||
|
|
||||||
// Import auth services
|
// Import auth services
|
||||||
import { auth } from './auth/auth-service'
|
import { auth } from './auth/auth-service'
|
||||||
|
|
@ -33,7 +32,6 @@ const imageUploadService = new ImageUploadService()
|
||||||
const nostrMetadataService = new NostrMetadataService()
|
const nostrMetadataService = new NostrMetadataService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
const nostrTransportService = new NostrTransportService()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base Module Plugin
|
* Base Module Plugin
|
||||||
|
|
@ -77,7 +75,6 @@ export const baseModule: ModulePlugin = {
|
||||||
// Register shared Nostr services (used by multiple modules)
|
// Register shared Nostr services (used by multiple modules)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
||||||
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
|
|
||||||
|
|
||||||
// Register PWA service
|
// Register PWA service
|
||||||
container.provide('pwaService', pwaService)
|
container.provide('pwaService', pwaService)
|
||||||
|
|
@ -125,10 +122,6 @@ export const baseModule: ModulePlugin = {
|
||||||
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
await nostrTransportService.initialize({
|
|
||||||
waitForDependencies: true, // NostrTransportService depends on RelayHub and AuthService
|
|
||||||
maxRetries: 3
|
|
||||||
})
|
|
||||||
// InvoiceService doesn't need initialization as it's not a BaseService
|
// InvoiceService doesn't need initialization as it's not a BaseService
|
||||||
|
|
||||||
console.log('✅ Base module installed successfully')
|
console.log('✅ Base module installed successfully')
|
||||||
|
|
@ -148,7 +141,6 @@ export const baseModule: ModulePlugin = {
|
||||||
await nostrMetadataService.dispose()
|
await nostrMetadataService.dispose()
|
||||||
await profileService.dispose()
|
await profileService.dispose()
|
||||||
await reactionService.dispose()
|
await reactionService.dispose()
|
||||||
await nostrTransportService.dispose()
|
|
||||||
// InvoiceService doesn't need disposal as it's not a BaseService
|
// InvoiceService doesn't need disposal as it's not a BaseService
|
||||||
await lnbitsAPI.dispose()
|
await lnbitsAPI.dispose()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
import { finalizeEvent, nip44, type EventTemplate, type Event } from 'nostr-tools'
|
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
|
||||||
import { LNBITS_CONFIG } from '@/lib/config/lnbits'
|
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
|
||||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for LNbits's nostr-transport (kind-21000 RPC over relays,
|
|
||||||
* landed upstream Sun May 24 2026, commit f235966c).
|
|
||||||
*
|
|
||||||
* The webapp wraps its RPC payload in an NIP-44 v2 encrypted JSON
|
|
||||||
* envelope, signs a kind-21000 event with the current user's Nostr
|
|
||||||
* key, publishes via RelayHub, and listens for a signed response
|
|
||||||
* from the LNbits server pubkey addressed back to us. Shards
|
|
||||||
* (per Lightning.Pub's `{part,index,totalShards,shardsId}` format)
|
|
||||||
* are reassembled before parsing.
|
|
||||||
*
|
|
||||||
* This is the preferred call site for any backend op that has a
|
|
||||||
* registered handler — the activities ticket scanner is the first
|
|
||||||
* webapp consumer; wallet ops + event CRUD are obvious next adopters.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TRANSPORT_KIND = 21000
|
|
||||||
|
|
||||||
interface RpcCallOptions {
|
|
||||||
/** Wallet id to send as part of the RPC body — required for
|
|
||||||
* AUTH_WALLET endpoints. Defaults to currentUser.wallets[0].id. */
|
|
||||||
walletId?: string
|
|
||||||
/** Per-call timeout in ms before throwing. Default 15s. */
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShardWrapper {
|
|
||||||
part: string
|
|
||||||
index: number
|
|
||||||
totalShards: number
|
|
||||||
shardsId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RpcRequestEnvelope {
|
|
||||||
rpc_name: string
|
|
||||||
request_id: string
|
|
||||||
wallet_id?: string
|
|
||||||
body?: Record<string, unknown>
|
|
||||||
query?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RpcResponseEnvelope<T = unknown> {
|
|
||||||
status: 'OK' | 'ERROR'
|
|
||||||
request_id: string
|
|
||||||
subscription_id?: string | null
|
|
||||||
data?: T | null
|
|
||||||
error?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NostrRpcError extends Error {
|
|
||||||
constructor(public readonly message: string) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'NostrRpcError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NostrTransportService extends BaseService {
|
|
||||||
protected readonly metadata = {
|
|
||||||
name: 'NostrTransportService',
|
|
||||||
version: '1.0.0',
|
|
||||||
dependencies: ['AuthService', 'RelayHub'],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server pubkey (hex). Logged by the LNbits server at startup
|
|
||||||
* and configured via VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY. */
|
|
||||||
private get serverPubkey(): string {
|
|
||||||
return LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async onInitialize(): Promise<void> {
|
|
||||||
if (!this.serverPubkey) {
|
|
||||||
this.debug(
|
|
||||||
'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail',
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.debug(`Initialized with server pubkey ${this.serverPubkey.slice(0, 16)}…`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invoke an LNbits nostr-transport RPC and await the response.
|
|
||||||
*
|
|
||||||
* Throws `NostrRpcError` on backend ERROR responses (e.g. "Ticket
|
|
||||||
* not paid for"), a generic Error on signing / encryption / timeout
|
|
||||||
* failures.
|
|
||||||
*/
|
|
||||||
async call<T = unknown>(
|
|
||||||
rpcName: string,
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
options: RpcCallOptions = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const auth = this.authService as AuthService | undefined
|
|
||||||
const user = auth?.currentUser.value
|
|
||||||
if (!user || !user.prvkey || !user.pubkey) {
|
|
||||||
throw new Error('Sign-in with a Nostr key required to call RPC')
|
|
||||||
}
|
|
||||||
if (!this.serverPubkey) {
|
|
||||||
throw new Error(
|
|
||||||
'Missing VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY — cannot reach LNbits',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const walletId = options.walletId ?? user.wallets?.[0]?.id
|
|
||||||
const timeoutMs = options.timeoutMs ?? 15_000
|
|
||||||
const requestId = crypto.randomUUID()
|
|
||||||
|
|
||||||
const privkeyBytes = hexToBytes(user.prvkey)
|
|
||||||
const conversationKey = nip44.v2.utils.getConversationKey(
|
|
||||||
privkeyBytes,
|
|
||||||
this.serverPubkey,
|
|
||||||
)
|
|
||||||
|
|
||||||
const envelope: RpcRequestEnvelope = {
|
|
||||||
rpc_name: rpcName,
|
|
||||||
request_id: requestId,
|
|
||||||
...(walletId ? { wallet_id: walletId } : {}),
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
const ciphertext = nip44.v2.encrypt(JSON.stringify(envelope), conversationKey)
|
|
||||||
|
|
||||||
const template: EventTemplate = {
|
|
||||||
kind: TRANSPORT_KIND,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['p', this.serverPubkey]],
|
|
||||||
content: ciphertext,
|
|
||||||
}
|
|
||||||
const signedEvent = finalizeEvent(template, privkeyBytes)
|
|
||||||
|
|
||||||
// Subscribe BEFORE publishing so we can't miss a fast reply.
|
|
||||||
const replyPromise = this.waitForReply<T>(
|
|
||||||
requestId,
|
|
||||||
user.pubkey,
|
|
||||||
conversationKey,
|
|
||||||
timeoutMs,
|
|
||||||
)
|
|
||||||
|
|
||||||
const hub = this.relayHub as RelayHub
|
|
||||||
const publishResult = await hub.publishEvent(signedEvent)
|
|
||||||
if (publishResult.success === 0) {
|
|
||||||
throw new Error('Failed to publish RPC event to any relay')
|
|
||||||
}
|
|
||||||
|
|
||||||
return replyPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForReply<T>(
|
|
||||||
requestId: string,
|
|
||||||
clientPubkey: string,
|
|
||||||
conversationKey: Uint8Array,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<T> {
|
|
||||||
const hub = this.relayHub as RelayHub
|
|
||||||
const shards = new Map<string, Map<number, string>>()
|
|
||||||
const shardsExpected = new Map<string, number>()
|
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
unsubscribe?.()
|
|
||||||
reject(new Error(`RPC ${requestId} timed out after ${timeoutMs}ms`))
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
unsubscribe = hub.subscribe({
|
|
||||||
id: `nostr-rpc-${requestId}`,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
kinds: [TRANSPORT_KIND],
|
|
||||||
'#p': [clientPubkey],
|
|
||||||
authors: [this.serverPubkey],
|
|
||||||
// Don't bound by `since` — the server may publish before
|
|
||||||
// the relay has accepted our subscription create. The
|
|
||||||
// request_id match below is the actual filter.
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onEvent: (event: Event) => {
|
|
||||||
let plaintext: string
|
|
||||||
try {
|
|
||||||
plaintext = nip44.v2.decrypt(event.content, conversationKey)
|
|
||||||
} catch (e) {
|
|
||||||
this.debug(`Skipping undecryptable event ${event.id.slice(0, 16)}: ${e}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reassemble shards (Lightning.Pub wrapper format) before
|
|
||||||
// attempting to parse as an RPC response. Plaintexts that
|
|
||||||
// don't carry the wrapper are full single-event responses.
|
|
||||||
let fullText: string | null = plaintext
|
|
||||||
try {
|
|
||||||
const maybeShard = JSON.parse(plaintext) as Partial<ShardWrapper>
|
|
||||||
if (
|
|
||||||
typeof maybeShard.shardsId === 'string' &&
|
|
||||||
typeof maybeShard.index === 'number' &&
|
|
||||||
typeof maybeShard.totalShards === 'number' &&
|
|
||||||
typeof maybeShard.part === 'string'
|
|
||||||
) {
|
|
||||||
const buf = shards.get(maybeShard.shardsId) ?? new Map<number, string>()
|
|
||||||
buf.set(maybeShard.index, maybeShard.part)
|
|
||||||
shards.set(maybeShard.shardsId, buf)
|
|
||||||
shardsExpected.set(maybeShard.shardsId, maybeShard.totalShards)
|
|
||||||
if (buf.size < maybeShard.totalShards) {
|
|
||||||
return // wait for more shards
|
|
||||||
}
|
|
||||||
const sorted = Array.from({ length: maybeShard.totalShards }, (_, i) =>
|
|
||||||
buf.get(i) ?? '',
|
|
||||||
)
|
|
||||||
fullText = sorted.join('')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON → fall through with plaintext as-is
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: RpcResponseEnvelope<T>
|
|
||||||
try {
|
|
||||||
response = JSON.parse(fullText) as RpcResponseEnvelope<T>
|
|
||||||
} catch (e) {
|
|
||||||
this.debug(`Skipping unparseable RPC payload: ${e}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response.request_id !== requestId) return // not for us
|
|
||||||
|
|
||||||
clearTimeout(timer)
|
|
||||||
unsubscribe?.()
|
|
||||||
if (response.status === 'OK') {
|
|
||||||
resolve(response.data as T)
|
|
||||||
} else {
|
|
||||||
reject(new NostrRpcError(response.error ?? 'Unknown RPC error'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToBytes(hex: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue