Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
199 lines
9.6 KiB
Markdown
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.
|