feat(activities): organizer ticket scanner over Nostr transport #73

Merged
padreug merged 4 commits from ticket-scanner-nostr-webapp into dev 2026-05-24 16:51:13 +00:00
Owner

Summary

Closes the activities loop: organizers can now scan attendees'
QRs from the standalone PWA at the door, no LNbits admin UI
detour. Every scan publishes a kind-21000 RPC event signed with
the organizer's Nostr key — the signed event IS the
authorization, no admin_key in the browser, no HTTP path
involved on the happy path.

Companion to aiolabs/events#19 which adds the backend
events_ticket_register RPC handler + secures the legacy HTTP
register endpoint.

Commits

Commit What
02c1be0 feat(base): NostrTransportService — nip44 v2 kind-21000 RPC client for LNbits
0f8f98d feat(activities): organizer ticket scanner over nostr-transport

How it works

  1. Organizer opens an activity they own → "Scan" button on
    ActivityDetailPage's top bar (gated on ownedLnbitsEvent).
  2. Routes to /scan/:activityId (new). The page renders
    <QRScanner> (existing component using useQRScanner +
    qr-scanner v1.4.2 already in package.json).
  3. On QR decode: useTicketScanner strips ticket://, dedups
    the in-session list (localStorage activities_scanned_<id>
    mirroring the LNbits admin page's pattern), then invokes
    NostrTransportService.call('events_ticket_register', { event_id, ticket_id }).
  4. Transport service builds the request envelope, NIP-44 v2
    encrypts to the server pubkey, signs the kind-21000 event
    with the user's Nostr key, publishes via the existing
    RelayHub, and listens for the signed response.
  5. Backend (PR #19) verifies the organizer owns the event,
    flips registered = True, returns the updated ticket. The
    webapp shows a green banner with the holder's name (if
    any) and appends to the scanned list.

Components

  • src/modules/base/services/NostrTransportService.ts
    generic kind-21000 RPC client. Handles NIP-44 v2 encrypt /
    decrypt (nostr-tools' nip44.v2), signing with the
    current user's prvkey via finalizeEvent, shard reassembly
    for responses larger than the per-event cap, 15s default
    timeout. Throws NostrRpcError on backend ERROR responses.
  • src/modules/activities/composables/useTicketScanner.ts
    stateful driver. onDecode is the QR handler;
    lastScan exposes { status: 'ok' | 'duplicate-session' | 'error', ticketId, ticket?, message? }; scanned persists
    the per-activity registered list to localStorage.
  • src/modules/activities/views/ScanTicketsPage.vue
    camera viewport + three-variant last-scan banner +
    scrollable scanned list with timestamps and holder names.
  • ActivityDetailPage.vueScan button next to the
    existing Edit affordance, same gating
    (ownedLnbitsEvent !== null).
  • .env.example + src/lib/config/lnbits.ts
    VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY for the server pubkey.

Wire contract

Matches the backend PR exactly:

  • rpc_name: events_ticket_register
  • body: { event_id, ticket_id }
  • OK data: full Ticket dict
  • ERROR cases: "Ticket not paid for" /
    "Ticket already registered" /
    "Ticket does not exist on this event" /
    "You do not own this event" /
    "Wallet access required (provide wallet_id)"

Backend dependency

Do not merge until aiolabs/events#19 has shipped and the
catalog entry is bumped on the host the webapp connects to.

Without the RPC handler registered, every scan attempt times
out at 15s.

Test plan

After backend is live on the target host:

  • As organizer on an activity you own → "Scan" button visible
    in the detail page top bar.
  • Click Scan → camera permission prompt → scanner starts.
  • Scan a valid paid ticket QR → green banner with holder name,
    entry appears in the scanned list with timestamp,
    localStorage["activities_scanned_<id>"] carries it.
  • Scan the same QR twice → yellow banner "Already scanned in
    this session", no second RPC fired.
  • Hard reload → scanned list persists.
  • Scan a ticket for a different event you own → red banner
    "Ticket does not exist on this event".
  • Scan an unpaid ticket → red banner "Ticket not paid for".
  • Open /scan/<id> for an event owned by someone else → the
    backend rejects with "You do not own this event" on first
    scan (route is reachable but harmless).
  • Kill the local relay mid-scan → 15s timeout with a clear
    error banner, scanned list state unchanged.

🤖 Generated with Claude Code

## Summary Closes the activities loop: organizers can now scan attendees' QRs from the standalone PWA at the door, no LNbits admin UI detour. Every scan publishes a kind-21000 RPC event signed with the organizer's Nostr key — the signed event IS the authorization, no admin_key in the browser, no HTTP path involved on the happy path. Companion to **aiolabs/events#19** which adds the backend `events_ticket_register` RPC handler + secures the legacy HTTP register endpoint. ## Commits | Commit | What | |---|---| | `02c1be0` | `feat(base): NostrTransportService — nip44 v2 kind-21000 RPC client for LNbits` | | `0f8f98d` | `feat(activities): organizer ticket scanner over nostr-transport` | ## How it works 1. Organizer opens an activity they own → "Scan" button on `ActivityDetailPage`'s top bar (gated on `ownedLnbitsEvent`). 2. Routes to `/scan/:activityId` (new). The page renders `<QRScanner>` (existing component using `useQRScanner` + `qr-scanner` v1.4.2 already in `package.json`). 3. On QR decode: `useTicketScanner` strips `ticket://`, dedups the in-session list (localStorage `activities_scanned_<id>` mirroring the LNbits admin page's pattern), then invokes `NostrTransportService.call('events_ticket_register', { event_id, ticket_id })`. 4. Transport service builds the request envelope, NIP-44 v2 encrypts to the server pubkey, signs the kind-21000 event with the user's Nostr key, publishes via the existing `RelayHub`, and listens for the signed response. 5. Backend (PR #19) verifies the organizer owns the event, flips `registered = True`, returns the updated ticket. The webapp shows a green banner with the holder's name (if any) and appends to the scanned list. ## Components - **`src/modules/base/services/NostrTransportService.ts`** — generic kind-21000 RPC client. Handles NIP-44 v2 encrypt / decrypt (`nostr-tools`' `nip44.v2`), signing with the current user's `prvkey` via `finalizeEvent`, shard reassembly for responses larger than the per-event cap, 15s default timeout. Throws `NostrRpcError` on backend ERROR responses. - **`src/modules/activities/composables/useTicketScanner.ts`** — stateful driver. `onDecode` is the QR handler; `lastScan` exposes `{ status: 'ok' | 'duplicate-session' | 'error', ticketId, ticket?, message? }`; `scanned` persists the per-activity registered list to localStorage. - **`src/modules/activities/views/ScanTicketsPage.vue`** — camera viewport + three-variant last-scan banner + scrollable scanned list with timestamps and holder names. - **`ActivityDetailPage.vue`** — `Scan` button next to the existing `Edit` affordance, same gating (`ownedLnbitsEvent !== null`). - **`.env.example` + `src/lib/config/lnbits.ts`** — `VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY` for the server pubkey. ## Wire contract Matches the backend PR exactly: - **rpc_name**: `events_ticket_register` - **body**: `{ event_id, ticket_id }` - **OK data**: full `Ticket` dict - **ERROR cases**: `"Ticket not paid for"` / `"Ticket already registered"` / `"Ticket does not exist on this event"` / `"You do not own this event"` / `"Wallet access required (provide wallet_id)"` ## Backend dependency **Do not merge until aiolabs/events#19 has shipped and the catalog entry is bumped on the host the webapp connects to.** Without the RPC handler registered, every scan attempt times out at 15s. ## Test plan After backend is live on the target host: - [x] As organizer on an activity you own → "Scan" button visible in the detail page top bar. - [x] Click Scan → camera permission prompt → scanner starts. - [x] Scan a valid paid ticket QR → green banner with holder name, entry appears in the scanned list with timestamp, `localStorage["activities_scanned_<id>"]` carries it. - [x] Scan the same QR twice → yellow banner "Already scanned in this session", no second RPC fired. - [x] Hard reload → scanned list persists. - [x] Scan a ticket for a different event you own → red banner "Ticket does not exist on this event". - [x] Scan an unpaid ticket → red banner "Ticket not paid for". - [x] Open `/scan/<id>` for an event owned by someone else → the backend rejects with "You do not own this event" on first scan (route is reachable but harmless). - [ ] Kill the local relay mid-scan → 15s timeout with a clear error banner, scanned list state unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Generic client for LNbits's nostr-transport (landed upstream Sun May
24, commit f235966c). Encrypts a request envelope to the server's
transport pubkey with NIP-44 v2, signs a kind-21000 event with the
current user's Nostr key, publishes via RelayHub, and listens for a
signed response addressed back to us. Shards (Lightning.Pub's
`{part, index, totalShards, shardsId}` wrapper) are reassembled
before parsing.

Activities ticket scanner is the first consumer; wallet ops + event
CRUD are obvious next adopters (file as follow-up). Server pubkey
discovery is currently env-var (VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY)
— see also the follow-up to add a `.well-known` discovery endpoint
on LNbits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the activities loop: organizers scan attendees' QRs from the
standalone PWA at the door instead of dropping into the LNbits admin
register page. Every scan invokes the events_ticket_register RPC
(see aiolabs/events#19) over the nostr transport — the organizer's
signed kind-21000 event IS the authorization, no admin_key in the
browser.

- useTicketScanner: stateful driver. Parses `ticket://<id>` URIs,
  dedups in-session via localStorage (`activities_scanned_<id>`,
  mirroring the LNbits admin page's `events_scanned_<eventId>`
  pattern), surfaces lastScan with three states (ok / duplicate-
  session / error). Backend errors arrive as NostrRpcError messages
  ("Ticket not paid for", "Ticket already registered", etc.) and
  render directly.
- ScanTicketsPage: camera viewport + last-scan banner +
  scrollable session list with timestamps and (when available)
  ticket-holder names. Three banner variants (success/warning/
  destructive) so the organizer can read at a glance.
- Route /scan/:activityId, gated by requiresAuth. The "Scan" entry
  button on ActivityDetailPage's top bar is rendered only when
  `ownedLnbitsEvent !== null`, matching the existing "Edit" gating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the "My tickets" chip from #71. Where "My tickets"
narrows the feed to events you're attending, "Hosting" narrows it
to events you're organizing — reading `activity.isMine` which
useActivities.tagOwnership() already populates from organizer
pubkey match + own LNbits drafts.

Naming rationale: "My events" would have been ambiguous with
favorited / bookmarked. "Hosting" is short, role-oriented, and
pairs as the natural counterpart to "My tickets" (attending vs.
organizing). Spanish/French translations lean on the verb form
("Organizo" / "J'organise") since those languages don't have a
clean noun equivalent.

- useActivityFilters: onlyHosting flag, toggleHosting action,
  resetFilters clears it, hasActiveFilters lights up.
- applyFilters filters by `a.isMine === true` when the flag is
  on. Composes with category / temporal / "My tickets" via the
  same intersection chain.
- ActivitiesPage: chip rendered alongside "My tickets" with the
  Megaphone icon (lucide). Hidden when logged out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without a pause gate the qr-scanner's 5-fps decode loop instantly
fires another scan on whatever QR is still in frame — most
visibly, the ticket that just registered immediately re-fires as
"already scanned this session". The operator at the door never
gets a beat to confirm the result or act on it (let the attendee
in, deny entry, redirect to manual lookup, etc.).

useTicketScanner gains an `isPaused` ref that flips to true the
moment a decode resolves (success, error, or duplicate-session
de-dup) and gates further `onDecode` calls. The camera keeps
streaming so resumption is instant — only the decode handler is
muted.

The page replaces the small "Dismiss" button with a full-width
"Scan next" CTA below the result banner. Same place every time so
the operator's hand can stay in muscle memory; disabled while the
in-flight RPC is still sending. Result banner upgrades to a
slightly larger icon + label so the success/failure is readable
at arm's length over the venue.

`clearScanned` also resets `isPaused` so the operator can recover
from a stuck state via the "Clear list" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.