chatelet/docs/event-flow.md
Padreug 43d071756e docs: document the NIP-17/59 check-in DM flow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 20:34:12 +02:00

199 lines
9.6 KiB
Markdown

# Chatelet — Event Flow
How rooms, guests, LNbits, and relays interact. The guiding rule:
> **LNbits is the booking authority. Nostr carries discovery, requests, and
> receipts — it never holds the lock.** The DB write is the lock; the
> Lightning payment is the confirmation.
Two doors lead into the *same* booking flow — the REST API
([`../views_api.py`](../views_api.py)) and the Nostr-transport RPC layer
([`../transport_rpcs.py`](../transport_rpcs.py)). Both delegate to
[`../services.py`](../services.py) (orchestration) over
[`../crud.py`](../crud.py) (persistence) so arbitration + quoting live in one
place and can't drift between doors.
## Door 2: RPC over the core nostr transport
The core LNbits nostr transport (`lnbits.core.services.nostr_transport`) is a
**kind-21000 encrypted RPC bus** (NIP-44), not a general event publisher.
Chatelet registers handlers on it in `chatelet_start()` so the whole booking
flow runs over relays with no HTTP:
| RPC | Auth | Purpose |
|---|---|---|
| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) |
| `chatelet_block_create` | wallet | operator blocks a range |
| `chatelet_room_list_mine` | account | operator's rooms across their wallets |
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) |
| `chatelet_availability` | none | is a range free + a quote |
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
trusted from the body.
**Payment confirmation over RPC:** `subscribe_payments` is wallet-owner
scoped, so the *operator* can stream booking settlements
(`subscribe_payments({tag:"chatelet", link_id:<booking_id>})`, wired via
`register_link_owner_resolver`). A **guest** can't subscribe to the operator's
wallet, so the guest confirms by polling `chatelet_booking_get` until
`confirmed` (a guest push would need a NIP-17 DM — issue #5).
> **Custom kinds vs the RPC — training wheels, not redundancy:** the
> `chatelet_availability` RPC is what ships first, but the ephemeral
> `kind:22000/22001` availability query/response (ADR-0001) is **retained as a
> proposal**, not dropped. The RPC is the training wheel; the public event kind
> is the client-agnostic destination — a generic Nostr client must one day be
> able to query availability *without* speaking our private RPC (workspace
> doctrine: extensions aim to be Nostr-client-agnostic). It will be published
> over the nostrclient relay path alongside the NIP-99/52 discovery events
> (issue #2).
## Actors
- **Operator** — the castle. Owns the LNbits account + Nostr identity that
signs listings and reservation receipts (via `resolve_signer`).
- **Guest** — a Nostr user (npub) discovering and booking a room.
- **LNbits (Chatelet ext)** — the authority: arbitrates availability, holds
dates, issues invoices, confirms on payment.
- **Relays** — dumb transport for discovery + messaging.
## Kinds at a glance
| Kind | NIP | Who signs | Purpose |
|---|---|---|---|
| `30402` | 99 | operator | Room listing (public, addressable) |
| `30078` | 78 | operator | Guest's reservation object (NIP-44 encrypted, addressable) |
| `31923/31924` | 52 | operator | Public availability calendar (no PII) |
| `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) |
| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) |
## Relay transport — nostrclient (in-process)
Publishing app/discovery events and subscribing to inbound queries go through
the **`nostrclient` extension's** relay manager, imported in-process
(`nostr_client.relay_manager.publish_message(...)` / `.add_subscription(...)`),
the same pattern `spirekeeper`/`nostrmarket` use. This is separate from the
core kind-21000 RPC transport (Door 2): the core pool is RPC-only and can't
publish arbitrary kinds, so app events ride nostrclient. Adds a **soft runtime
dependency** on nostrclient — if it isn't installed, publish/subscribe skip
with a logged warning and the booking flow (HTTP/RPC) is unaffected.
`nostr/service.py` implements it:
- **Publish** (`_sign_and_publish`): sign as the operator (`resolve_signer`),
optionally NIP-44-encrypt, then `publish_message`.
- **Subscribe** (`subscribe_inbound`, a permanent task): register a
`kind:22000` subscription, poll `NostrRouter.received_subscription_events`,
answer each with a `kind:22001` reply.
**Public vs encrypted — what works pre-bunker:** a `LocalSigner` can
`sign_event` but its `nip44_encrypt` raises (bunker-forward by design, lnbits
#18). So **public** events (listing `30402`, calendar `31923`, availability
`22001` — availability is public info, so `22000/22001` are plaintext) publish
today; **encrypted** events (reservation `30078`, and the check-in DM)
sign-encrypt via the operator signer and soft-fail with a clear log until the
operator has a bunker/server-signing signer. Nothing crashes either way.
### Check-in DM (NIP-17 / NIP-59) — issue #5
On settlement (`tasks.on_invoice_paid`), the guest is sent their private
check-in details (address, gate code from `room.checkin_instructions`, plus
times/policy from settings) as a **gift-wrapped** DM, built in
`nostr/giftwrap.py` from core primitives (no vendored crypto):
1. **rumor** (kind 14, unsigned) — the message; sender is the operator.
2. **seal** (kind 13) — NIP-44-encrypts the rumor to the guest, **signed by
the operator** via the signer abstraction (bunker-forward; this is the
layer that soft-fails on a LocalSigner).
3. **gift wrap** (kind 1059) — NIP-44-encrypts the seal with a throwaway
**ephemeral** key (core `nip44_encrypt` + `sign_event`, local — no bunker
round-trip); only public metadata is the recipient `p`-tag. Seal + wrap
`created_at` are randomised into the past per NIP-59.
Best-effort: a DM failure never undoes a confirmed, paid booking.
## Happy path
```mermaid
sequenceDiagram
participant O as Operator (LNbits)
participant R as Relays
participant G as Guest
Note over O,R: Discovery
O->>R: publish kind:30402 listing (per room)
O->>R: publish kind:31923 blocked ranges (no PII)
G->>R: subscribe kind:30402 (+ optional 31923)
Note over G,O: Availability (live)
G->>R: kind:22000 query {room, in, out} (NIP-44)
R->>O: deliver query
O->>O: crud.is_available()
O->>R: kind:22001 {available, quote_sat} (NIP-44)
R->>G: deliver response
Note over G,O: Booking request → hold
G->>R: NIP-59 giftwrap: booking request
R->>O: deliver request
O->>O: is_available? → write `held` booking (amount_sat canonical)
O->>O: create LNbits invoice (deposit_sat)
O->>R: NIP-59 giftwrap: quote {bolt11, expires_at}
R->>G: deliver quote (status: awaiting_payment)
Note over G,O: Payment confirms (NOT a nostr event)
G->>O: pay bolt11 (Lightning)
O->>O: invoice listener → status = confirmed, dates hard-blocked
O->>R: publish/replace kind:30078 reservation (NIP-44 to guest)
O->>R: NIP-59 giftwrap: check-in details (address, gate code, times)
R->>G: deliver receipt + check-in
```
If the guest never pays, the hold-expiry sweep
([`../tasks.py`](../tasks.py) `expire_holds_loop`) flips the booking to
`expired` after `default_hold_minutes` and the dates free themselves.
## Why payment (not a Nostr confirm event) is the commit point
A "confirm" event could be forged, replayed, or arrive out of order, and
relays give no ordering or delivery guarantees. The Lightning payment is
unforgeable and already the thing we actually care about. So the invoice
listener is the *only* writer that sets `confirmed`. Nostr just announces
the result.
## Concurrency — the check-then-hold lock
`is_available()` followed by writing the `held` row is the critical section:
two simultaneous requests for the same nights could both pass the read before
either writes. Because the check counts *occupying* bookings (`held` included),
once one request wins and writes `held`, the loser's re-check fails →
`Unavailable`/`409` — so the fix only needs to make that read+write atomic.
**Implemented** (`services.request_booking`): a per-room `asyncio.Lock`
(`_room_locks[room_id]`) wraps exactly the `is_available``create_booking`
pair. FX conversion and invoice creation are computed outside the lock so it's
held only for the DB critical section. Both doors (HTTP + RPC) go through
`services.request_booking`, so the lock covers every entry point.
**Scope / caveat:** an `asyncio.Lock` only serializes within one event loop.
LNbits runs a single worker, so this is sufficient today. If it ever runs
multi-worker/multi-process, this must become a DB-level guard — a Postgres
exclusion constraint on the date range, or `SELECT … FOR UPDATE` on the room
row — since separate processes don't share the lock.
## Cancellation & refunds (design intent)
- Cancel frees dates immediately (status → `cancelled`) and republishes the
`kind:30078` reservation with the new status so the guest's copy updates.
- Refund policy (`settings.cancellation_policy`) drives whether/how much is
returned — via LNURL-withdraw or a manual payout. Not auto-refunding on
chain; kept operator-mediated for a small castle.
## Nostr-native direction
Per the workspace long-term goal (webapp ↔ LNbits over Nostr, no HTTP), the
Nostr door is primary and REST is transitional. Keep new booking logic in
`crud.py` so both doors share it — don't grow HTTP-only paths that later
need ripping out.