Commit graph

33 commits

Author SHA1 Message Date
42a5f12fad Merge pull request 'chore: prep v0.1.0 for the catalog (fix SQLite migration + icon)' (#13) from chore/catalog-release-prep into main v0.1.0
Reviewed-on: #13
2026-07-19 21:17:11 +00:00
e1f1629662 chore: add extension icon + fix broken tile reference
config.json tile pointed at a non-existent chatelet.png. Add the shared aio
logo (static/image/aio.png) and point the tile at it — matches the fleet
convention (satmachineadmin/spirekeeper) and gives the catalog a valid icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 23:15:05 +02:00
7c249f08f6 fix: correct schema-qualified CREATE INDEX syntax (breaks SQLite install)
CREATE INDEX ... ON chatelet.bookings is invalid SQLite grammar — the schema
qualifier must go on the INDEX name, not the table (SQLite attaches the ext
DB as schema `chatelet`). Would have failed m001 on every SQLite-backed
install (the default backend). Fixed to `CREATE INDEX chatelet.idx_... ON
<table>`, matching the restaurant extension. Caught by a real-DB migration
smoke (m001+m002 applied, room+booking round-tripped) before tagging; safe to
amend m001 in place since chatelet has never been installed anywhere yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 23:15:05 +02:00
65f5c19a96 Merge pull request 'feat: NIP-17 gift-wrapped check-in DM on confirmation (#5)' (#12) from feat/checkin-dm into main
Reviewed-on: #12
2026-07-19 20:55:33 +00:00
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
910db8156d test: NIP-59 gift wrap structure + crypto round-trip
Assert the wrap is kind 1059, p-tagged to the guest, authored by an
ephemeral key (not the operator), and that plaintext doesn't leak. Round-
trip: decrypt the wrap with the guest key via core nip44_decrypt to recover
the operator-authored seal (kind 13) — proves the ephemeral NIP-44 v2 layer
is real + interoperable, not just structural. Soft-fail case returns None.
24 pass.

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
b6ca1b0e02 feat: NIP-17 gift-wrapped check-in DM on confirmation (#5)
On settlement, send the guest their private check-in details as a NIP-59
gift-wrapped DM (nostr/giftwrap.py, built from lnbits core primitives — no
vendored crypto):

- rumor (kind 14) -> seal (kind 13, operator-encrypted + operator-signed via
  the signer abstraction) -> gift wrap (kind 1059, ephemeral-key encrypted +
  signed locally via core nip44_encrypt + sign_event). created_at randomised
  into the past per NIP-59.
- service.send_checkin_dm builds the message (room.checkin_instructions +
  settings times/policy) and publishes via nostrclient (_publish_signed,
  extracted from _sign_and_publish).
- tasks.on_invoice_paid calls it best-effort — a DM failure never undoes a
  confirmed, paid booking.

Encrypted layer (seal) soft-fails on a LocalSigner until bunker/server-
signing (lnbits#18), same as the reservation event; the ephemeral wrap layer
always works.

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
df95fd37dc feat: add Room.checkin_instructions + migration
Private per-room access details (address, gate/door code). Sent to the
guest only in the encrypted check-in DM after payment — never in the
public listing. m002 adds the column (default '').

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
733f17600f Merge pull request 'feat: Nostr relay publish + availability subscription via nostrclient (#2)' (#11) from feat/nostr-relay-publish into main
Reviewed-on: #11
2026-07-19 17:33:33 +00:00
7b8dd82a6e docs: relay-transport (nostrclient) section + dependency note
Document the in-process nostrclient publish/subscribe path, the public-vs-
encrypted split (encrypted events await bunker/server-signing), and the
soft runtime dependency on the nostrclient extension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 18:09:35 +02:00
1465a30a01 test: event-builder shape (30402/30078/31923/22001)
Pure tests (no signing/relays): listing tags (d/title/price/status/g/t),
reservation carries canonical amount_sat as plaintext JSON, calendar
start/end + no broken 'a' tag, availability response plaintext. 20 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 18:09:35 +02:00
cf9f8699da feat: relay publish + availability subscription via nostrclient (#2)
Wire the Nostr layer through the nostrclient extension's relay manager
in-process (spirekeeper pattern), replacing the sketch stubs:

- _sign_and_publish: sign as operator (resolve_signer), optional NIP-44
  encrypt, publish via nostr_client.relay_manager.publish_message. Soft-
  fails (logs, returns None) if no operator onboarded, nostrclient absent,
  or signer can't encrypt — never crashes the booking flow.
- publish_listing (30402) + publish_block_calendar (31923): public, work
  today (sign_event only).
- publish_reservation (30078): NIP-44 encrypted to guest; soft-fails on a
  LocalSigner until a bunker/server-signing signer lands (lnbits#18).
- subscribe_inbound: permanent task answering kind:22000 availability
  queries with kind:22001 (plaintext — availability is public info), the
  client-agnostic availability path parallel to the RPC.

events.py: drop the _plaintext scaffolding (service.py encrypts content in
place) and a broken {operator_pubkey} calendar tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 18:09:35 +02:00
efd46a72c7 Merge pull request 'test: add test harness + suite (incl. #4 concurrency regression)' (#10) from feat/tests-harness into main
Reviewed-on: #10
2026-07-19 15:49:25 +00:00
7d704a8549 test: booking-flow, availability, and #4 concurrency regression
16 tests, run under the LNbits pytest env (spirekeeper pattern: monkeypatch
crud/invoice, drive async via asyncio.run — no live DB/wallet):

- test_availability: half-open overlap semantics (back-to-back stays OK),
  and is_available blocking on held/confirmed bookings + manual blocks.
- test_booking_flow: canonical amount_sat, awaiting_payment transition,
  min-nights guard, and hold-release (declined) on InvoiceError.
- test_atomic_hold: the #4 regression — two concurrent same-date requests
  yield exactly one hold + one conflict; non-overlapping both succeed. The
  fakes yield mid-check to open the race window, so the test fails without
  the per-room lock (verified by neutering it) and passes with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 17:48:11 +02:00
8b50263aac chore: add test + lint tooling (pyproject, Makefile)
Poetry/pytest/ruff/black/mypy config mirroring the aio extension lint
pipeline (spirekeeper template). `make test` runs pytest; `make check`
runs the linters. Enables a tests/ suite for the extension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 17:48:11 +02:00
27863db444 Merge pull request 'fix: make check-then-hold atomic against concurrent bookings (#4)' (#9) from feat/atomic-hold into main
Reviewed-on: #9
2026-07-19 15:09:28 +00:00
9d87c129ad fix: make check-then-hold atomic against concurrent bookings (#4)
Two simultaneous requests for the same nights could both pass is_available()
before either wrote its held row, double-booking the dates. Wrap the
is_available -> create_booking pair in a per-room asyncio.Lock
(_room_locks[room_id]) in services.request_booking, which both the HTTP and
RPC doors funnel through. FX + invoice creation stay outside the lock, so it
covers only the DB critical section.

Single-loop scope (LNbits runs one worker); documented the multi-worker
caveat (needs a DB-level guard) in event-flow.md and the crud.is_available
note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 17:08:43 +02:00
f2229fcb0a Merge pull request 'feat: expose Chatelet over the LNbits nostr transport (#1)' (#8) from feat/nostr-transport-rpcs into main
Reviewed-on: #8
2026-07-19 14:50:38 +00:00
1b65890f42 docs: reframe kind:22000/22001 as retained proposal, not redundant
Per the client-agnostic doctrine (workspace CLAUDE.md): the availability RPC
is the training wheel, the public kind:22000/22001 is the destination. Keep
the custom kinds as a proposal so a generic Nostr client can one day query
availability without our RPC. Published via the nostrclient relay path (#2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 15:37:06 +02:00
5b176e5186 feat: expose Chatelet over the LNbits nostr transport (#1)
Register kind-21000 RPC handlers on the core nostr_transport dispatcher so
the booking flow runs over relays with no HTTP, mirroring lnurlp:

- operator (AUTH_WALLET): room create/update/publish, block create — all
  ownership-checked; room_list_mine (AUTH_ACCOUNT).
- public (AUTH_NONE): room_list/get (wallet id stripped), availability,
  booking_request, booking_get. Guest identity is the signed sender_pubkey,
  so no guest_pubkey is trusted from the body.
- register_link_owner_resolver(tag=chatelet, key=booking_id) lets the
  operator stream settlements via subscribe_payments.

Handlers delegate to services.py — no logic duplicated. Graceful no-op if
the core transport module isn't in this LNbits build (pre-#4). Guests can't
subscribe to the operator wallet, so they poll booking_get to confirm.

Note documented in event-flow.md: with availability now an RPC, the custom
kind:22000/22001 pair is redundant for RPC clients (revisit in #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 01:30:13 +02:00
5cfc9b893d refactor: extract booking flow into services.py
Move availability quoting + the check-then-hold + invoice orchestration out
of views_api.py into a transport-agnostic services.py, with typed errors
(NotFound/Unavailable/ValueError/BookingError). views_api becomes a thin
HTTP door that maps those to status codes. No behavior change — this is so
the incoming nostr-transport door can share one booking flow instead of
duplicating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 01:30:13 +02:00
0cc419e5fa Merge pull request 'feat: wire FX + deposit invoice into the booking hold (#3)' (#7) from feat/payment-settle into main
Reviewed-on: #7
2026-07-18 23:22:50 +00:00
4df137190f feat: wire FX + deposit invoice into the booking hold (#3)
Booking requests now complete end-to-end over HTTP:

- _to_sats() converts fiat->sats via fiat_amount_as_satoshis (sat/sats
  pass through). This is the single conversion point; the result is the
  canonical amount_sat and is not recomputed downstream.
- api_request_booking creates a sats-denominated deposit invoice (locked
  amount, immune to FX drift before payment), tagged {tag:chatelet,
  booking_id} so tasks.on_invoice_paid matches the settle. On InvoiceError
  the hold is released (status=declined) so dead holds don't block dates.
- New BookingQuote response returns the held booking + bolt11 + hash.

Settlement (tasks.on_invoice_paid: awaiting_payment -> confirmed, dates
hard-blocked, reservation republished) was already in place and now fires
on real payments. Testable on FakeWallet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:26:14 +02:00
dfd54123bb docs: data-model + event-flow reference
data-model.md: entities, the canonical-amount_sat and derived-availability
invariants, booking lifecycle diagram. event-flow.md: actor/kind map, the
happy-path sequence diagram, why payment is the commit point, and the
check-then-hold concurrency requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:57 +02:00
910c284a7d feat: extension entrypoint wiring
Ties the layers together: router prefix, static mount, and the permanent
tasks (invoice listener + hold-expiry). Inbound Nostr subscription is
wired-but-commented pending relay plumbing. Extension is now loadable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:57 +02:00
43cfa5d921 feat: background tasks — settle on payment, expire holds
Payment (not any nostr event) is the commit point: the invoice listener
promotes held/awaiting_payment bookings to confirmed, hard-blocks the
dates, and republishes the encrypted reservation object. expire_holds_loop
sweeps lapsed holds every minute so abandoned requests free their dates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:57 +02:00
d5df9ab662 feat: REST API + operator admin route
views_api.py exposes the booking flow over HTTP — availability check,
guest booking request (check-then-hold with canonical amount_sat), room
publish, blocks. REST and Nostr are two doors into the same crud flow;
FX + invoice creation marked TODO. views.py serves the operator page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:57 +02:00
900b682986 feat: nostr event layer (builders + sign/publish service)
Implements ADR-0001. kinds.py pins the allocations; events.py has pure
builders (30402 listing, 30078 encrypted reservation, 31923 blocked-range
calendar, 22001 availability response); service.py signs/encrypts via
resolve_signer (spirekeeper hybrid pattern — no nsec at rest) and sketches
publish + inbound subscription. Relay plumbing marked TODO(relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:39 +02:00
f0c24ebba6 docs: ADR-0001 nostr event model
Records the decision to compose standard NIPs (99 listing, 78 reservation,
52 calendar, 17/59 DMs) and reserve aiolabs kind:22000/22001 only for the
ephemeral availability RPC. Documents rejected alternatives (bespoke 2200x
booking record, RSVP-as-booking, deprecated NIP-15) and flags the 22000/1
allocation to register in workspace CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:39 +02:00
e7fabd57bf feat: CRUD + availability arbiter
Room/booking/block persistence, and is_available() — the authority for
whether a range is free: room active AND no occupying booking or block
overlaps [check_in, check_out) (half-open, so back-to-back stays are fine).
expire_stale_holds() frees dates for lapsed unpaid holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:39 +02:00
95e5bcb136 feat: schema migrations
settings/rooms/bookings/blocks in ext_chatelet, with room+status and
payment_hash indexes for the overlap and settle hot paths. Ordinary
migrations (not the fork split — this is aio-original, no upstream to
rebase onto); still idempotent-friendly. No availability table by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:23 +02:00
ab892cabec feat: data model
Pydantic entities: ChateletSettings (operator/castle config), Room (a
rentable unit == one NIP-99 listing), Booking (a reservation), Block
(manual owner unavailability), plus availability query/result DTOs.

Two invariants encoded here: amount_sat is the canonical booking total
(never re-derived downstream), and availability is derived, never stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:23 +02:00
c756ac7cf6 chore: scaffold chatelet LNbits extension
Nostr-native room rentals (Airbnb-style) for the castle. Establishes the
extension manifest, license, README with the design overview, and static
+ template placeholders. No booking logic yet — subsequent commits build
the model, schema, arbiter, Nostr layer, API, tasks, and wiring bottom-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:16:23 +02:00