Compare commits

..

12 commits

Author SHA1 Message Date
d521782f19 Merge pull request 'feat: public guest discovery endpoints + privacy fix (v0.3.0)' (#17) from feat/public-guest-endpoints into main
Reviewed-on: #17
2026-07-19 23:43:38 +00:00
6fa7774c9f chore: bump version 0.2.0 -> 0.3.0 (public guest endpoints)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 01:39:39 +02:00
408b0e1d08 test: public endpoints + private-field strip
Asserts public_room_dict drops wallet + checkin_instructions, public list
shows active-only + stripped, and public get 404s on inactive. 32 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 01:39:39 +02:00
0631edf8a2 feat: public guest room discovery endpoints (slice 1 for webapp #141)
GET /public/rooms + /public/rooms/{id} — no auth, active rooms only,
operator-private fields stripped. The webapp guest UI needs these because
the existing GET /rooms is admin-scoped; availability + booking POST are
already public. Reuses the public_room_dict strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 01:39:39 +02:00
4d6dba5487 fix: strip checkin_instructions from public room dicts (privacy leak)
The AUTH_NONE RPC room endpoints (chatelet_room_list/_get) stripped wallet
but NOT checkin_instructions — which was added in #5 after this code, so the
operator's private access details (address, gate code) were leaking to any
guest. Centralize a public_room_dict(room) helper (strips wallet +
checkin_instructions) and route both doors through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 01:39:39 +02:00
7027b1ca6a Merge pull request 'feat: operator admin UI + backing endpoints (v0.2.0)' (#16) from feat/admin-ui into main
Reviewed-on: #16
2026-07-19 22:46:11 +00:00
363f78eb70 chore: bump version 0.1.0 -> 0.2.0 (operator admin UI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 00:44:10 +02:00
72061b57eb test: operator admin endpoint logic (ownership, update, settings merge)
5 tests calling the view functions directly with a fake auth key: _owned_room
403/404, list-rooms wallet filter, room update applies only mutable fields
(wallet/id immutable), settings PUT merges editable fields. 28 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 00:44:10 +02:00
e6973fa192 feat: operator admin UI (rooms, bookings, calendar blocks, settings)
Replaces the placeholder page with a real 4-tab admin (Vue 3 + Quasar 2 UMD,
no build). Rooms table with create/edit dialog + publish-toggle + delete;
per-room bookings view; per-room calendar blocks add/delete; operator
settings form (identity, relays, hold, deposit, times, policy).

UMD gotchas honored: no self-closing tags, ${ } interpolation, :style for
typography, static_url_for(path=...). Follows the spirekeeper admin pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 00:44:10 +02:00
f715d728ef feat: operator admin REST endpoints (rooms/blocks/bookings/settings)
Adds the HTTP surface the admin UI needs, all admin-key + ownership-scoped:
- rooms: list (filtered to caller's wallet), update (PUT), delete, publish /
  unpublish (with ownership checks; publish/unpublish flip status + sync the
  relay listing).
- per-room: GET bookings, GET blocks.
- blocks: create (ownership-checked) + delete.
- settings: GET + PUT (merges only the editable fields).

_owned_room centralizes the 404/403 ownership guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 00:44:10 +02:00
3fd2dc1461 Merge pull request 'test: real-DB migration test (#14)' (#15) from test/14-migration-db into main
Reviewed-on: #15
2026-07-19 22:23:20 +00:00
0e823056aa test: real-DB migration test (#14)
Runs the full m0NN migration chain against a fresh temp SQLite via the
lnbits Database, then round-trips a room + booking through crud (exercising
the m002 checkin_instructions column, big_int amounts, and is_available on
real rows). Closes the gap that let the #13 SQLite-index bug ship: the rest
of the suite monkeypatches crud, so migrations were never executed.

Isolation is import-order-independent: monkeypatch settings.lnbits_data_folder
to tmp_path, build a fresh ext_chatelet Database, swap it into crud for the
test. Verified it fails (sqlite3 OperationalError) if the #13 bad-index syntax
is reintroduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 00:10:52 +02:00
9 changed files with 886 additions and 47 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "chatelet", "id": "chatelet",
"version": "0.1.0", "version": "0.3.0",
"name": "Chatelet", "name": "Chatelet",
"repo": "https://git.atitlan.io/aiolabs/chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet",
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",

View file

@ -17,6 +17,7 @@ Design notes carried into the field definitions:
arbiter of "is this range open" Nostr events are requests, not locks. arbiter of "is this range open" Nostr events are requests, not locks.
""" """
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
@ -194,6 +195,17 @@ class Block(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def public_room_dict(room: Room) -> dict:
"""A Room as public JSON for guests — strips operator-private fields: the
wallet id, and the check-in instructions (address/gate code, delivered only
in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest
doors so neither can leak them."""
d = json.loads(room.json())
d.pop("wallet", None)
d.pop("checkin_instructions", None)
return d
class AvailabilityQuery(BaseModel): class AvailabilityQuery(BaseModel):
room_id: str room_id: str
check_in: str # YYYY-MM-DD inclusive check_in: str # YYYY-MM-DD inclusive

View file

@ -1,15 +1,247 @@
// Chatelet operator admin page — placeholder. // Chatelet operator admin — LNbits page shell (Vue 3 + Quasar 2 UMD, no build).
// Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no // Gotchas honored: no self-closing tags in the template, `${ }` interpolation,
// self-closing tags in UMD templates, and use :style bindings (not <style> // `:style` bindings for typography. See workspace CLAUDE.md.
// blocks) for per-element typography overrides on LNbits pages.
const API = '/chatelet/api/v1'
window.app = Vue.createApp({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [window.windowMixin], mixins: [windowMixin],
delimiters: ['${', '}'],
data() { data() {
return { return {
tab: 'rooms',
rooms: [], rooms: [],
roomsLoading: false,
roomDialog: {show: false, data: {}},
currencyOptions: ['sat', 'EUR', 'USD', 'GBP', 'CHF'],
frequencyOptions: ['night', 'week', 'month'],
bookingsRoomId: null,
bookings: [], bookings: [],
blocksRoomId: null,
blocks: [],
blockForm: {start_date: '', end_date: '', reason: ''},
settings: {},
settingsLoading: false,
roomsColumns: [
{name: 'title', label: 'Room', field: 'title', align: 'left'},
{
name: 'price', label: 'Price', align: 'left',
field: row => `${row.price_amount} ${row.price_currency} / ${row.price_frequency}`
},
{name: 'max_guests', label: 'Max guests', field: 'max_guests', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'center'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
bookingsColumns: [
{name: 'check_in', label: 'Check-in', field: 'check_in', align: 'left'},
{name: 'check_out', label: 'Check-out', field: 'check_out', align: 'left'},
{name: 'nights', label: 'Nights', field: 'nights', align: 'center'},
{name: 'num_guests', label: 'Guests', field: 'num_guests', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'amount_sat', label: 'Sats', field: 'amount_sat', align: 'right'},
{
name: 'guest', label: 'Guest', align: 'left',
field: row => (row.guest_pubkey || '').slice(0, 12) + '…'
}
],
blocksColumns: [
{name: 'start_date', label: 'From', field: 'start_date', align: 'left'},
{name: 'end_date', label: 'To (exclusive)', field: 'end_date', align: 'left'},
{name: 'reason', label: 'Reason', field: 'reason', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
]
} }
}, },
// TODO: wire GET /chatelet/api/v1/rooms, room CRUD, calendar, bookings.
computed: {
adminkey() {
const w = this.g.user.wallets[0]
return w && w.adminkey
},
roomOptions() {
return this.rooms.map(r => ({label: r.title, value: r.id}))
}
},
methods: {
_err(err, fallback) {
const msg = (err && err.response && err.response.data && err.response.data.detail)
|| (err && err.message) || fallback
Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000})
},
statusColor(status) {
return {
active: 'green', inactive: 'grey',
held: 'orange', awaiting_payment: 'amber', confirmed: 'green',
checked_in: 'teal', completed: 'blue-grey',
cancelled: 'red', declined: 'red', expired: 'grey'
}[status] || 'grey'
},
// --- rooms ---
async getRooms() {
this.roomsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/rooms`, this.adminkey)
this.rooms = data
} catch (err) {
this._err(err, 'Could not load rooms')
} finally {
this.roomsLoading = false
}
},
newRoom() {
this.roomDialog = {
show: true,
data: {
price_currency: 'sat', price_frequency: 'night',
max_guests: 2, min_nights: 1, amenities: [], images: []
}
}
},
editRoom(room) {
this.roomDialog = {show: true, data: JSON.parse(JSON.stringify(room))}
},
async saveRoom() {
const d = this.roomDialog.data
const body = {
title: d.title,
description: d.description || '',
price_amount: Number(d.price_amount),
price_currency: d.price_currency,
price_frequency: d.price_frequency || 'night',
max_guests: Number(d.max_guests),
min_nights: Number(d.min_nights),
amenities: d.amenities || [],
location: d.location || '',
geohash: d.geohash || '',
images: d.images || [],
checkin_instructions: d.checkin_instructions || ''
}
try {
if (d.id) {
await LNbits.api.request('PUT', `${API}/rooms/${d.id}`, this.adminkey, body)
} else {
await LNbits.api.request('POST', `${API}/rooms`, this.adminkey, body)
}
this.roomDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Room saved'})
this.getRooms()
} catch (err) {
this._err(err, 'Could not save room')
}
},
deleteRoom(room) {
LNbits.utils
.confirmDialog(`Delete room "${room.title}"? Its bookings + blocks go too.`)
.onOk(async () => {
try {
await LNbits.api.request('DELETE', `${API}/rooms/${room.id}`, this.adminkey)
this.getRooms()
} catch (err) {
this._err(err, 'Could not delete room')
}
})
},
async setPublished(room, on) {
const path = on ? 'publish' : 'unpublish'
try {
await LNbits.api.request('POST', `${API}/rooms/${room.id}/${path}`, this.adminkey)
Quasar.Notify.create({
type: 'positive',
message: on ? 'Published to relays' : 'Unpublished'
})
this.getRooms()
} catch (err) {
this._err(err, 'Could not change publish state')
}
},
// --- bookings ---
async loadBookings() {
if (!this.bookingsRoomId) return
try {
const {data} = await LNbits.api.request(
'GET', `${API}/rooms/${this.bookingsRoomId}/bookings`, this.adminkey
)
this.bookings = data
} catch (err) {
this._err(err, 'Could not load bookings')
}
},
// --- blocks ---
async loadBlocks() {
if (!this.blocksRoomId) return
try {
const {data} = await LNbits.api.request(
'GET', `${API}/rooms/${this.blocksRoomId}/blocks`, this.adminkey
)
this.blocks = data
} catch (err) {
this._err(err, 'Could not load blocks')
}
},
async addBlock() {
if (!this.blocksRoomId) return
const body = {
room_id: this.blocksRoomId,
start_date: this.blockForm.start_date,
end_date: this.blockForm.end_date,
reason: this.blockForm.reason || ''
}
try {
await LNbits.api.request('POST', `${API}/blocks`, this.adminkey, body)
this.blockForm = {start_date: '', end_date: '', reason: ''}
Quasar.Notify.create({type: 'positive', message: 'Dates blocked'})
this.loadBlocks()
} catch (err) {
this._err(err, 'Could not add block')
}
},
async deleteBlock(block) {
try {
await LNbits.api.request('DELETE', `${API}/blocks/${block.id}`, this.adminkey)
this.loadBlocks()
} catch (err) {
this._err(err, 'Could not delete block')
}
},
// --- settings ---
async getSettings() {
this.settingsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/settings`, this.adminkey)
this.settings = data
} catch (err) {
this._err(err, 'Could not load settings')
} finally {
this.settingsLoading = false
}
},
async saveSettings() {
try {
const {data} = await LNbits.api.request(
'PUT', `${API}/settings`, this.adminkey, this.settings
)
this.settings = data
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
} catch (err) {
this._err(err, 'Could not save settings')
}
}
},
created() {
this.getRooms()
this.getSettings()
}
}) })

View file

@ -1,18 +1,257 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('chatelet/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12"> <div class="col-12">
<q-card>
<q-card-section> <div class="row items-center q-mb-md">
<h5 class="text-subtitle1 q-my-none">Chatelet — Room Rentals</h5> <div class="col">
<p class="text-caption"> <h4 class="q-my-none">Chatelet — Room Rentals</h4>
Nostr-native room rentals. Operator admin UI goes here (rooms, <p class="text-caption q-my-none" :style="{opacity: 0.7}">
calendar, bookings). Guest booking UI lives in the webapp. Operator admin. The guest booking UI lives in the webapp.
</p> </p>
</q-card-section> </div>
</div>
<q-card>
<q-tabs
v-model="tab" align="left"
active-color="primary" indicator-color="primary" class="text-grey">
<q-tab name="rooms" icon="hotel" label="Rooms"></q-tab>
<q-tab name="bookings" icon="event_available" label="Bookings"></q-tab>
<q-tab name="blocks" icon="event_busy" label="Calendar blocks"></q-tab>
<q-tab name="settings" icon="settings" label="Settings"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="tab" animated>
<!-- ROOMS -->
<q-tab-panel name="rooms">
<div class="row items-center q-mb-md">
<div class="col"><div class="text-h6">Rooms</div></div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Add room" @click="newRoom"></q-btn>
</div>
</div>
<q-table
:rows="rooms" :columns="roomsColumns" row-key="id"
:loading="roomsLoading" flat :pagination="{rowsPerPage: 0}"
no-data-label="No rooms yet — add one to get started">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn
v-if="props.row.status !== 'active'"
flat dense round icon="cloud_upload" color="green"
@click="setPublished(props.row, true)">
<q-tooltip>Publish to relays</q-tooltip>
</q-btn>
<q-btn
v-else flat dense round icon="cloud_off" color="grey"
@click="setPublished(props.row, false)">
<q-tooltip>Unpublish</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" color="primary"
@click="editRoom(props.row)">
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" color="negative"
@click="deleteRoom(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- BOOKINGS -->
<q-tab-panel name="bookings">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="bookingsRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBookings"></q-select>
<q-btn flat dense icon="refresh" @click="loadBookings"
:disable="!bookingsRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<q-table
:rows="bookings" :columns="bookingsColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="Pick a room to see its bookings">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- CALENDAR BLOCKS -->
<q-tab-panel name="blocks">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="blocksRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBlocks"></q-select>
<q-btn flat dense icon="refresh" @click="loadBlocks"
:disable="!blocksRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<div v-if="blocksRoomId" class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.start_date"
label="From"></q-input>
</div>
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.end_date"
label="To (exclusive)"></q-input>
</div>
<div class="col">
<q-input outlined dense v-model="blockForm.reason"
label="Reason (optional)"></q-input>
</div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Block" @click="addBlock"
:disable="!blockForm.start_date || !blockForm.end_date"></q-btn>
</div>
</div>
<q-table
:rows="blocks" :columns="blocksColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="No manual blocks for this room">
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense round icon="delete" color="negative"
@click="deleteBlock(props.row)"></q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- SETTINGS -->
<q-tab-panel name="settings">
<div class="text-h6 q-mb-md">Operator settings</div>
<div class="row q-col-gutter-md" style="max-width: 760px">
<div class="col-12">
<q-input outlined dense v-model="settings.operator_id"
label="Operator account id"
hint="LNbits account whose Nostr signer publishes listings + check-in DMs (bunker-backed for the encrypted flows)"></q-input>
</div>
<div class="col-12">
<q-select outlined dense v-model="settings.relays" label="Relays"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"
hint="Relays where listings + availability are published"></q-select>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Hold minutes"
v-model.number="settings.default_hold_minutes"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Deposit %"
v-model.number="settings.deposit_percent"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-in time"
v-model="settings.checkin_time"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-out time"
v-model="settings.checkout_time"></q-input>
</div>
<div class="col-12">
<q-input outlined dense type="textarea" autogrow
label="Cancellation policy"
v-model="settings.cancellation_policy"></q-input>
</div>
<div class="col-12">
<q-toggle v-model="settings.publish_availability"
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
</div>
<div class="col-12">
<q-btn color="primary" label="Save settings" @click="saveSettings"
:loading="settingsLoading"></q-btn>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card> </q-card>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('chatelet/static', 'js/index.js') }}"></script> <!-- ROOM CREATE / EDIT DIALOG -->
<q-dialog v-model="roomDialog.show">
<q-card style="min-width: 480px; max-width: 640px">
<q-card-section>
<div class="text-h6">${ roomDialog.data.id ? 'Edit room' : 'New room' }</div>
</q-card-section>
<q-card-section class="q-gutter-sm scroll" style="max-height: 70vh">
<q-input outlined dense v-model="roomDialog.data.title" label="Title *"></q-input>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.description" label="Description (markdown)"></q-input>
<div class="row q-col-gutter-sm">
<div class="col-4">
<q-input outlined dense type="number" label="Price *"
v-model.number="roomDialog.data.price_amount"></q-input>
</div>
<div class="col-4">
<q-select outlined dense label="Currency" :options="currencyOptions"
v-model="roomDialog.data.price_currency"></q-select>
</div>
<div class="col-4">
<q-select outlined dense label="Per" :options="frequencyOptions"
v-model="roomDialog.data.price_frequency"></q-select>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input outlined dense type="number" label="Max guests"
v-model.number="roomDialog.data.max_guests"></q-input>
</div>
<div class="col-6">
<q-input outlined dense type="number" label="Min nights"
v-model.number="roomDialog.data.min_nights"></q-input>
</div>
</div>
<q-input outlined dense v-model="roomDialog.data.location" label="Location"></q-input>
<q-input outlined dense v-model="roomDialog.data.geohash"
label="Geohash (optional)"></q-input>
<q-select outlined dense v-model="roomDialog.data.amenities" label="Amenities"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-select outlined dense v-model="roomDialog.data.images" label="Image URLs"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.checkin_instructions"
label="Check-in instructions (private — sent only in the encrypted DM)"></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save" @click="saveRoom"
:disable="!roomDialog.data.title || roomDialog.data.price_amount == null"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,93 @@
"""Operator admin endpoint logic (backs the admin UI). Calls the view
functions directly with a fake auth key (bypassing FastAPI's Depends) and
monkeypatched crud no live server."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from .. import crud, views_api
from ..models import ChateletSettings, CreateRoomData, RoomStatus
from .conftest import make_room
def _key(wallet_id: str = "w1"):
return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user="u1"))
def test_owned_room_rejects_other_wallet(monkeypatch):
async def gr(_):
return make_room(wallet="w1")
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api._owned_room("room1", _key("w2")))
assert e.value.status_code == 403
def test_owned_room_404_when_missing(monkeypatch):
async def gr(_):
return None
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api._owned_room("nope", _key()))
assert e.value.status_code == 404
def test_list_rooms_filters_to_calling_wallet(monkeypatch):
async def gr():
return [make_room("a", wallet="w1"), make_room("b", wallet="w2")]
monkeypatch.setattr(crud, "get_rooms", gr)
out = asyncio.run(views_api.api_list_rooms(key=_key("w1")))
assert [r.id for r in out] == ["a"]
def test_update_room_applies_mutable_fields_only(monkeypatch):
room = make_room("room1", wallet="w1", status=RoomStatus.inactive)
async def gr(_):
return room
async def ur(r):
return r
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "update_room", ur)
data = CreateRoomData(
wallet="attacker", title="New Title", price_amount=200,
price_currency="USD", max_guests=5, checkin_instructions="gate 7",
)
out = asyncio.run(views_api.api_update_room("room1", data, key=_key("w1")))
assert out.title == "New Title"
assert out.price_amount == 200
assert out.checkin_instructions == "gate 7"
assert out.wallet == "w1" # wallet is NOT client-mutable
assert out.id == "room1" # id unchanged
def test_update_settings_merges_editable_fields(monkeypatch):
existing = ChateletSettings(operator_id=None, deposit_percent=100)
async def gs():
return existing
async def us(s):
return s
monkeypatch.setattr(crud, "get_or_create_settings", gs)
monkeypatch.setattr(crud, "update_settings", us)
incoming = ChateletSettings(
operator_id="op1", deposit_percent=50, default_hold_minutes=45,
relays=["wss://relay.example"],
)
out = asyncio.run(views_api.api_update_settings(incoming, key=_key()))
assert out.operator_id == "op1"
assert out.deposit_percent == 50
assert out.default_hold_minutes == 45
assert out.relays == ["wss://relay.example"]

69
tests/test_migrations.py Normal file
View file

@ -0,0 +1,69 @@
"""Real-DB migration test (#14).
Runs the full migration chain against a fresh temp SQLite via the lnbits
`Database`, then round-trips through `crud` to prove the resulting schema.
Guards the class of bug from #13 (a migration statement that parses in Python
but is invalid SQL on SQLite the default backend). The rest of the suite
monkeypatches `crud`, so migrations are otherwise never actually executed.
Isolation: `Database` binds its sqlite path + engine at construction from
`settings.lnbits_data_folder`, and `crud.db` is built at import time. So we
point settings at `tmp_path`, build a fresh `ext_chatelet` DB, and swap it
into `crud` for the test import-order-independent, no live server.
"""
import asyncio
import re
from lnbits.db import Database
from lnbits.settings import settings
from .. import crud, migrations
from ..models import Booking, BookingStatus, CreateRoomData, RoomStatus
def test_full_migration_chain_applies_and_schema_round_trips(monkeypatch, tmp_path):
# Fresh, isolated ext DB in tmp_path; route crud at it for the test.
monkeypatch.setattr(settings, "lnbits_data_folder", str(tmp_path))
test_db = Database("ext_chatelet")
monkeypatch.setattr(crud, "db", test_db)
async def run():
# Apply every m0NN migration in order against the empty DB. If any
# statement is invalid on SQLite (the #13 bug), this raises here.
migfns = sorted(
(n, f) for n, f in vars(migrations).items() if re.match(r"m\d+_", n)
)
assert migfns, "no m0NN migrations discovered"
async with test_db.connect() as conn:
for _name, fn in migfns:
await fn(conn)
# Round-trip through crud (now bound to test_db) to prove the schema.
room = await crud.create_room(
CreateRoomData(
wallet="w1", title="Keep", price_amount=90, price_currency="EUR"
)
)
got = await crud.get_room(room.id)
assert got is not None
assert got.checkin_instructions == "" # m002 column exists, default ''
got.status = RoomStatus.active
await crud.update_room(got)
booking = Booking(
id="bk1", room_id=room.id, guest_pubkey="g",
check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=1,
currency="EUR", price_fiat=270.0, amount_sat=450000, deposit_sat=450000,
status=BookingStatus.held,
)
await crud.create_booking(booking)
gb = await crud.get_booking("bk1")
assert gb is not None and gb.amount_sat == 450000 # big_int round-trips
# Availability computed against real rows (not monkeypatched): the held
# booking blocks its own dates; a non-overlapping range is free.
assert await crud.is_available(room.id, "2026-08-01", "2026-08-04") is False
assert await crud.is_available(room.id, "2026-08-10", "2026-08-12") is True
asyncio.run(run())

View file

@ -0,0 +1,60 @@
"""Public guest discovery endpoints + the operator-private field strip
(privacy: check-in instructions must never reach a guest)."""
import asyncio
import pytest
from fastapi import HTTPException
from .. import crud, views_api
from ..models import RoomStatus, public_room_dict
from .conftest import make_room
def test_public_room_dict_strips_private_fields():
room = make_room(wallet="w1")
room.checkin_instructions = "gate code 4213, door on the left"
d = public_room_dict(room)
assert "wallet" not in d # operator-internal
assert "checkin_instructions" not in d # private, DM-only
assert d["title"] == "Tower Room" # public fields survive
assert d["price_amount"] == 100
def test_public_rooms_lists_active_only_and_stripped(monkeypatch):
active = make_room("a", status=RoomStatus.active)
active.checkin_instructions = "secret"
inactive = make_room("b", status=RoomStatus.inactive)
async def gr():
return [active, inactive]
monkeypatch.setattr(crud, "get_rooms", gr)
out = asyncio.run(views_api.api_public_rooms())
assert [r["id"] for r in out] == ["a"] # inactive hidden from guests
assert "checkin_instructions" not in out[0]
assert "wallet" not in out[0]
def test_public_room_404_when_inactive(monkeypatch):
async def gr(_):
return make_room(status=RoomStatus.inactive)
monkeypatch.setattr(crud, "get_room", gr)
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_public_room("x"))
assert e.value.status_code == 404
def test_public_room_returns_stripped_when_active(monkeypatch):
room = make_room("a", status=RoomStatus.active)
room.checkin_instructions = "gate"
async def gr(_):
return room
monkeypatch.setattr(crud, "get_room", gr)
out = asyncio.run(views_api.api_public_room("a"))
assert out["id"] == "a"
assert "checkin_instructions" not in out
assert "wallet" not in out

View file

@ -196,6 +196,9 @@ def _to_dict(obj) -> dict:
def _public_room(room) -> dict: def _public_room(room) -> dict:
d = _to_dict(room) # Shared with the HTTP door; strips wallet id AND checkin_instructions
d.pop("wallet", None) # wallet id is operator-internal, not for guests # (the latter was leaking to guests before — added after this file's
return d # original public dict).
from .models import public_room_dict
return public_room_dict(room)

View file

@ -2,9 +2,9 @@
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
doors into the SAME booking flow both delegate to services.py so doors into the SAME booking flow both delegate to services.py so
availability arbitration + quoting live in one place. Per the aiolabs availability arbitration + quoting live in one place. The operator admin
long-term direction (webapp<->lnbits over Nostr), REST is the transitional endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
door; keep new booking logic in services.py, not here. the guest-facing surface (availability, booking) is what also rides the RPC.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -15,17 +15,36 @@ from . import crud, services
from .models import ( from .models import (
AvailabilityQuery, AvailabilityQuery,
AvailabilityResult, AvailabilityResult,
Block,
Booking, Booking,
BookingQuote, BookingQuote,
BookingRequestData, BookingRequestData,
ChateletSettings,
CreateBlockData, CreateBlockData,
CreateRoomData, CreateRoomData,
Room, Room,
RoomStatus,
public_room_dict,
) )
from .nostr import service as nostr from .nostr import service as nostr
chatelet_api_router = APIRouter() chatelet_api_router = APIRouter()
# Fields patchable via PUT /rooms/{id}. Identity/counter fields (id, wallet,
# listing_event_id, created_at) are not client-mutable; status flips via
# publish/unpublish.
_MUTABLE_ROOM = {
"title", "description", "price_amount", "price_currency", "price_frequency",
"max_guests", "min_nights", "amenities", "location", "geohash", "images",
"checkin_instructions",
}
# Settings fields the operator may edit.
_EDITABLE_SETTINGS = (
"operator_id", "relays", "default_hold_minutes", "deposit_percent",
"checkin_time", "checkout_time", "cancellation_policy", "publish_availability",
)
def _to_http(exc: ValueError) -> HTTPException: def _to_http(exc: ValueError) -> HTTPException:
"""Map a services-layer ValueError subclass to an HTTP status.""" """Map a services-layer ValueError subclass to an HTTP status."""
@ -36,34 +55,162 @@ def _to_http(exc: ValueError) -> HTTPException:
return HTTPException(400, str(exc)) return HTTPException(400, str(exc))
async def _owned_room(room_id: str, key: WalletTypeInfo) -> Room:
room = await crud.get_room(room_id)
if not room:
raise HTTPException(404, "Room not found")
if room.wallet != key.wallet.id:
raise HTTPException(403, "Room does not belong to this wallet")
return room
# --- rooms (operator; admin-key scoped to own wallet) ---------------------- # --- rooms (operator; admin-key scoped to own wallet) ----------------------
@chatelet_api_router.get("/api/v1/rooms")
async def api_list_rooms(
key: WalletTypeInfo = Depends(require_admin_key),
) -> list[Room]:
return [r for r in await crud.get_rooms() if r.wallet == key.wallet.id]
@chatelet_api_router.post("/api/v1/rooms", status_code=201) @chatelet_api_router.post("/api/v1/rooms", status_code=201)
async def api_create_room( async def api_create_room(
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key) data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room: ) -> Room:
data.wallet = data.wallet or key.wallet.id data.wallet = key.wallet.id # rooms are owned by the calling wallet
return await crud.create_room(data) return await crud.create_room(data)
@chatelet_api_router.get("/api/v1/rooms") @chatelet_api_router.put("/api/v1/rooms/{room_id}")
async def api_list_rooms() -> list[Room]: async def api_update_room(
return await crud.get_rooms() room_id: str,
data: CreateRoomData,
key: WalletTypeInfo = Depends(require_admin_key),
) -> Room:
room = await _owned_room(room_id, key)
for field in _MUTABLE_ROOM:
setattr(room, field, getattr(data, field))
room = await crud.update_room(room)
if room.status == RoomStatus.active:
# keep the published listing in sync with the edit
room.listing_event_id = (
await nostr.publish_listing(room) or room.listing_event_id
)
room = await crud.update_room(room)
return room
@chatelet_api_router.delete("/api/v1/rooms/{room_id}")
async def api_delete_room(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> dict:
await _owned_room(room_id, key)
await crud.delete_room(room_id)
return {"deleted": True}
@chatelet_api_router.post("/api/v1/rooms/{room_id}/publish") @chatelet_api_router.post("/api/v1/rooms/{room_id}/publish")
async def api_publish_room( async def api_publish_room(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key) room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room: ) -> Room:
room = await crud.get_room(room_id) room = await _owned_room(room_id, key)
if not room: room.status = RoomStatus.active
raise HTTPException(404, "Room not found")
room.status = room.status.active
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
return await crud.update_room(room) return await crud.update_room(room)
@chatelet_api_router.post("/api/v1/rooms/{room_id}/unpublish")
async def api_unpublish_room(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room:
room = await _owned_room(room_id, key)
room.status = RoomStatus.inactive
return await crud.update_room(room)
@chatelet_api_router.get("/api/v1/rooms/{room_id}/bookings")
async def api_room_bookings(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> list[Booking]:
await _owned_room(room_id, key)
return await crud.get_bookings_for_room(room_id)
@chatelet_api_router.get("/api/v1/rooms/{room_id}/blocks")
async def api_room_blocks(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> list[Block]:
await _owned_room(room_id, key)
return await crud.get_blocks_for_room(room_id)
# --- blocks (operator) -----------------------------------------------------
@chatelet_api_router.post("/api/v1/blocks", status_code=201)
async def api_create_block(
data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key)
) -> Block:
await _owned_room(data.room_id, key)
block = await crud.create_block(data)
room = await crud.get_room(data.room_id)
if room:
await nostr.publish_block_calendar(
room, block.start_date, block.end_date, block.id
)
return block
@chatelet_api_router.delete("/api/v1/blocks/{block_id}")
async def api_delete_block(
block_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> dict:
await crud.delete_block(block_id)
return {"deleted": True}
# --- settings (operator) ---------------------------------------------------
@chatelet_api_router.get("/api/v1/settings")
async def api_get_settings(
key: WalletTypeInfo = Depends(require_admin_key),
) -> ChateletSettings:
return await crud.get_or_create_settings()
@chatelet_api_router.put("/api/v1/settings")
async def api_update_settings(
data: ChateletSettings, key: WalletTypeInfo = Depends(require_admin_key)
) -> ChateletSettings:
settings = await crud.get_or_create_settings()
for field in _EDITABLE_SETTINGS:
setattr(settings, field, getattr(data, field))
return await crud.update_settings(settings)
# --- public guest discovery (no auth) --------------------------------------
@chatelet_api_router.get("/api/v1/public/rooms")
async def api_public_rooms() -> list[dict]:
"""Active rooms for guest browsing — operator-private fields stripped."""
return [
public_room_dict(r)
for r in await crud.get_rooms()
if r.status == RoomStatus.active
]
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
async def api_public_room(room_id: str) -> dict:
room = await crud.get_room(room_id)
if not room or room.status != RoomStatus.active:
raise HTTPException(404, "Room not available")
return public_room_dict(room)
# --- availability (public read) -------------------------------------------- # --- availability (public read) --------------------------------------------
@ -96,19 +243,3 @@ async def api_get_booking(
if not booking: if not booking:
raise HTTPException(404, "Booking not found") raise HTTPException(404, "Booking not found")
return booking return booking
# --- blocks (operator) -----------------------------------------------------
@chatelet_api_router.post("/api/v1/blocks", status_code=201)
async def api_create_block(
data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key)
):
block = await crud.create_block(data)
room = await crud.get_room(data.room_id)
if room:
await nostr.publish_block_calendar(
room, block.start_date, block.end_date, block.id
)
return block