/** * rBnb module — community hospitality rApp. * * Trust-based space sharing and couch surfing within community networks. * No platform extraction — leverages rNetwork trust graph, supports gift economy * as a first-class option, keeps all data local-first via Automerge CRDTs. * * All persistence uses Automerge documents via SyncServer — * no PostgreSQL dependency. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { bnbSchema, bnbDocId } from './schemas'; import type { BnbDoc, Listing, AvailabilityWindow, StayRequest, StayMessage, Endorsement, SpaceConfig, EconomyModel, ListingType, StayStatus, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Local-first helpers ── function ensureDoc(space: string): BnbDoc { const docId = bnbDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init bnb', (d) => { const init = bnbSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.config = init.config; d.listings = {}; d.availability = {}; d.stays = {}; d.endorsements = {}; }); _syncServer!.setDoc(docId, doc); } return doc; } function daysFromNow(days: number): number { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(0, 0, 0, 0); return d.getTime(); } // ── JSON response helpers ── function listingToRow(l: Listing) { return { id: l.id, host_did: l.hostDid, host_name: l.hostName, title: l.title, description: l.description, type: l.type, economy: l.economy, suggested_amount: l.suggestedAmount, currency: l.currency, sliding_min: l.slidingMin, sliding_max: l.slidingMax, exchange_description: l.exchangeDescription, location_name: l.locationName, location_lat: l.locationLat, location_lng: l.locationLng, location_granularity: l.locationGranularity, guest_capacity: l.guestCapacity, bedroom_count: l.bedroomCount, bed_count: l.bedCount, bathroom_count: l.bathroomCount, amenities: l.amenities, house_rules: l.houseRules, photos: l.photos, cover_photo: l.coverPhoto, trust_threshold: l.trustThreshold, instant_accept: l.instantAccept, is_active: l.isActive, created_at: l.createdAt ? new Date(l.createdAt).toISOString() : null, updated_at: l.updatedAt ? new Date(l.updatedAt).toISOString() : null, }; } function availabilityToRow(a: AvailabilityWindow) { return { id: a.id, listing_id: a.listingId, start_date: new Date(a.startDate).toISOString().split('T')[0], end_date: new Date(a.endDate).toISOString().split('T')[0], status: a.status, notes: a.notes, created_at: a.createdAt ? new Date(a.createdAt).toISOString() : null, }; } function stayToRow(s: StayRequest) { return { id: s.id, listing_id: s.listingId, guest_did: s.guestDid, guest_name: s.guestName, host_did: s.hostDid, check_in: new Date(s.checkIn).toISOString().split('T')[0], check_out: new Date(s.checkOut).toISOString().split('T')[0], guest_count: s.guestCount, status: s.status, messages: s.messages.map(m => ({ id: m.id, sender_did: m.senderDid, sender_name: m.senderName, body: m.body, sent_at: new Date(m.sentAt).toISOString(), })), offered_amount: s.offeredAmount, offered_currency: s.offeredCurrency, offered_exchange: s.offeredExchange, requested_at: s.requestedAt ? new Date(s.requestedAt).toISOString() : null, responded_at: s.respondedAt ? new Date(s.respondedAt).toISOString() : null, completed_at: s.completedAt ? new Date(s.completedAt).toISOString() : null, cancelled_at: s.cancelledAt ? new Date(s.cancelledAt).toISOString() : null, }; } function endorsementToRow(e: Endorsement) { return { id: e.id, stay_id: e.stayId, listing_id: e.listingId, author_did: e.authorDid, author_name: e.authorName, subject_did: e.subjectDid, subject_name: e.subjectName, direction: e.direction, body: e.body, rating: e.rating, tags: e.tags, visibility: e.visibility, trust_weight: e.trustWeight, created_at: e.createdAt ? new Date(e.createdAt).toISOString() : null, }; } // ── Seed demo data ── function seedDemoIfEmpty(space: string) { const docId = bnbDocId(space); const doc = ensureDoc(space); if (Object.keys(doc.listings).length > 0) return; _syncServer!.changeDoc(docId, 'seed demo data', (d) => { const now = Date.now(); // ── 6 Demo Listings ── const listing1 = crypto.randomUUID(); d.listings[listing1] = { id: listing1, hostDid: 'did:key:z6MkDemoHost1', hostName: 'Marta K.', title: 'Cozy Couch in Kreuzberg Co-op', description: 'A comfortable pull-out couch in our community living room. Shared kitchen, rooftop garden access. We host weekly dinners on Thursdays — guests welcome to join!', type: 'couch', economy: 'gift', suggestedAmount: null, currency: null, slidingMin: null, slidingMax: null, exchangeDescription: null, locationName: 'Kreuzberg, Berlin', locationLat: 52.4934, locationLng: 13.4014, locationGranularity: 'neighborhood', guestCapacity: 1, bedroomCount: null, bedCount: 1, bathroomCount: 1, amenities: ['wifi', 'kitchen', 'laundry', 'garden', 'linens'], houseRules: ['shoes_off', 'quiet_hours', 'no_smoking'], photos: [], coverPhoto: null, trustThreshold: 20, instantAccept: true, isActive: true, createdAt: now - 86400000 * 45, updatedAt: now - 86400000 * 3, }; const listing2 = crypto.randomUUID(); d.listings[listing2] = { id: listing2, hostDid: 'did:key:z6MkDemoHost2', hostName: 'Tomás R.', title: 'Permaculture Farm Stay — Help & Learn', description: 'Private room in the farmhouse. Join morning chores in exchange for your stay: feeding chickens, harvesting vegetables, composting. Meals included from the farm kitchen.', type: 'room', economy: 'exchange', suggestedAmount: null, currency: null, slidingMin: null, slidingMax: null, exchangeDescription: '3-4 hours of farm work per day (morning shift). All meals from the farm kitchen included.', locationName: 'Alentejo, Portugal', locationLat: 38.5667, locationLng: -7.9, locationGranularity: 'region', guestCapacity: 2, bedroomCount: 1, bedCount: 1, bathroomCount: 1, amenities: ['kitchen', 'garden', 'parking', 'linens', 'towels', 'hot_water'], houseRules: ['no_smoking', 'clean_up_after', 'no_parties'], photos: [], coverPhoto: null, trustThreshold: 15, instantAccept: false, isActive: true, createdAt: now - 86400000 * 60, updatedAt: now - 86400000 * 5, }; const listing3 = crypto.randomUUID(); d.listings[listing3] = { id: listing3, hostDid: 'did:key:z6MkDemoHost3', hostName: 'Anika S.', title: 'Lakeside Tent Site in Mecklenburg', description: 'Pitch your tent by the lake on our community land. Composting toilet, outdoor shower (solar-heated), fire pit, and kayak available. Pure quiet — no cars, no lights, just stars.', type: 'tent_site', economy: 'suggested', suggestedAmount: 10, currency: 'EUR', slidingMin: null, slidingMax: null, exchangeDescription: null, locationName: 'Mecklenburg Lake District, Germany', locationLat: 53.45, locationLng: 12.7, locationGranularity: 'region', guestCapacity: 4, bedroomCount: null, bedCount: null, bathroomCount: 1, amenities: ['parking', 'garden', 'pets_welcome'], houseRules: ['no_parties', 'clean_up_after'], photos: [], coverPhoto: null, trustThreshold: 10, instantAccept: true, isActive: true, createdAt: now - 86400000 * 30, updatedAt: now - 86400000 * 2, }; const listing4 = crypto.randomUUID(); d.listings[listing4] = { id: listing4, hostDid: 'did:key:z6MkDemoHost4', hostName: 'Lucia V.', title: 'Artist Loft in Neukölln — Sliding Scale', description: 'Bright, spacious loft in a converted factory. Art supplies and a shared studio space available. Great for creative retreats. Pay what feels right.', type: 'loft', economy: 'sliding_scale', suggestedAmount: 35, currency: 'EUR', slidingMin: 15, slidingMax: 60, exchangeDescription: null, locationName: 'Neukölln, Berlin', locationLat: 52.4811, locationLng: 13.4346, locationGranularity: 'neighborhood', guestCapacity: 2, bedroomCount: 1, bedCount: 1, bathroomCount: 1, amenities: ['wifi', 'kitchen', 'workspace', 'heating', 'hot_water', 'linens', 'towels'], houseRules: ['shoes_off', 'no_smoking', 'quiet_hours'], photos: [], coverPhoto: null, trustThreshold: 25, instantAccept: false, isActive: true, createdAt: now - 86400000 * 20, updatedAt: now - 86400000 * 1, }; const listing5 = crypto.randomUUID(); d.listings[listing5] = { id: listing5, hostDid: 'did:key:z6MkDemoHost5', hostName: 'Commons Hub Berlin', title: 'Commons Hub Guest Room — Gift Economy', description: 'A dedicated guest room in our co-working and community hub. Access to shared kitchen, meeting rooms, library, and rooftop terrace. Visitors are part of the community for the duration of their stay.', type: 'room', economy: 'gift', suggestedAmount: null, currency: null, slidingMin: null, slidingMax: null, exchangeDescription: null, locationName: 'Mitte, Berlin', locationLat: 52.52, locationLng: 13.405, locationGranularity: 'neighborhood', guestCapacity: 2, bedroomCount: 1, bedCount: 2, bathroomCount: 1, amenities: ['wifi', 'kitchen', 'workspace', 'laundry', 'heating', 'hot_water', 'linens', 'towels', 'wheelchair_accessible'], houseRules: ['no_smoking', 'quiet_hours', 'clean_up_after', 'shoes_off'], photos: [], coverPhoto: null, trustThreshold: 30, instantAccept: true, isActive: true, createdAt: now - 86400000 * 90, updatedAt: now - 86400000 * 7, }; const listing6 = crypto.randomUUID(); d.listings[listing6] = { id: listing6, hostDid: 'did:key:z6MkDemoHost6', hostName: 'Jakob W.', title: 'Off-Grid Cabin in the Black Forest', description: 'A small wooden cabin heated by wood stove. No electricity, no internet — just forest, silence, and a creek. Solar lantern and firewood provided. Ideal for digital detox.', type: 'cabin', economy: 'fixed', suggestedAmount: 45, currency: 'EUR', slidingMin: null, slidingMax: null, exchangeDescription: null, locationName: 'Black Forest, Germany', locationLat: 48.0, locationLng: 8.2, locationGranularity: 'region', guestCapacity: 2, bedroomCount: 1, bedCount: 1, bathroomCount: null, amenities: ['parking', 'pets_welcome', 'heating'], houseRules: ['no_smoking', 'clean_up_after', 'check_in_by_10pm'], photos: [], coverPhoto: null, trustThreshold: 40, instantAccept: false, isActive: true, createdAt: now - 86400000 * 15, updatedAt: now - 86400000 * 1, }; // ── Availability windows ── const avail = (listingId: string, startDays: number, endDays: number, status: 'available' | 'blocked' = 'available') => { const id = crypto.randomUUID(); d.availability[id] = { id, listingId, startDate: daysFromNow(startDays), endDate: daysFromNow(endDays), status, notes: null, createdAt: now, }; }; // Listing 1 (Kreuzberg couch) — available next 2 months avail(listing1, 0, 60); // Listing 2 (Farm) — available next month, then blocked avail(listing2, 0, 30); avail(listing2, 31, 45, 'blocked'); avail(listing2, 46, 90); // Listing 3 (Tent site) — seasonal, available May-September feel avail(listing3, 0, 120); // Listing 4 (Artist loft) — some gaps avail(listing4, 3, 20); avail(listing4, 25, 50); // Listing 5 (Commons hub) — available year-round avail(listing5, 0, 180); // Listing 6 (Cabin) — weekends only for next month, then open avail(listing6, 5, 7); avail(listing6, 12, 14); avail(listing6, 19, 21); avail(listing6, 26, 28); avail(listing6, 30, 90); // ── Sample stay requests ── const stay1 = crypto.randomUUID(); d.stays[stay1] = { id: stay1, listingId: listing1, guestDid: 'did:key:z6MkDemoGuest1', guestName: 'Ravi P.', hostDid: 'did:key:z6MkDemoHost1', checkIn: daysFromNow(5), checkOut: daysFromNow(8), guestCount: 1, status: 'accepted', messages: [ { id: crypto.randomUUID(), senderDid: 'did:key:z6MkDemoGuest1', senderName: 'Ravi P.', body: 'Hi Marta! I\'m visiting Berlin for a commons conference and would love to crash at your place. I\'m quiet, clean, and happy to cook!', sentAt: now - 86400000 * 3, }, { id: crypto.randomUUID(), senderDid: 'did:key:z6MkDemoHost1', senderName: 'Marta K.', body: 'Welcome Ravi! You\'re auto-accepted (trust score 72). Thursday dinner is lasagna night — you\'re invited. Key code will be sent day-of.', sentAt: now - 86400000 * 3 + 3600000, }, ], offeredAmount: null, offeredCurrency: null, offeredExchange: null, requestedAt: now - 86400000 * 3, respondedAt: now - 86400000 * 3 + 3600000, completedAt: null, cancelledAt: null, }; const stay2 = crypto.randomUUID(); d.stays[stay2] = { id: stay2, listingId: listing2, guestDid: 'did:key:z6MkDemoGuest2', guestName: 'Elena M.', hostDid: 'did:key:z6MkDemoHost2', checkIn: daysFromNow(-14), checkOut: daysFromNow(-7), guestCount: 1, status: 'completed', messages: [ { id: crypto.randomUUID(), senderDid: 'did:key:z6MkDemoGuest2', senderName: 'Elena M.', body: 'I\'d love to learn permaculture! I have experience with composting and seed saving.', sentAt: now - 86400000 * 21, }, { id: crypto.randomUUID(), senderDid: 'did:key:z6MkDemoHost2', senderName: 'Tomás R.', body: 'Perfect! We\'re starting a new compost system this week. Come join us.', sentAt: now - 86400000 * 20, }, ], offeredAmount: null, offeredCurrency: null, offeredExchange: 'Help with composting and seed saving', requestedAt: now - 86400000 * 21, respondedAt: now - 86400000 * 20, completedAt: now - 86400000 * 7, cancelledAt: null, }; const stay3 = crypto.randomUUID(); d.stays[stay3] = { id: stay3, listingId: listing4, guestDid: 'did:key:z6MkDemoGuest3', guestName: 'Kai L.', hostDid: 'did:key:z6MkDemoHost4', checkIn: daysFromNow(10), checkOut: daysFromNow(14), guestCount: 1, status: 'pending', messages: [ { id: crypto.randomUUID(), senderDid: 'did:key:z6MkDemoGuest3', senderName: 'Kai L.', body: 'Hi Lucia, I\'m a muralist and would love to stay in your loft for a few days while working on a commission nearby. Happy to pay sliding scale.', sentAt: now - 86400000 * 1, }, ], offeredAmount: 30, offeredCurrency: 'EUR', offeredExchange: null, requestedAt: now - 86400000 * 1, respondedAt: null, completedAt: null, cancelledAt: null, }; // ── Sample endorsements ── const endorsement1 = crypto.randomUUID(); d.endorsements[endorsement1] = { id: endorsement1, stayId: stay2, listingId: listing2, authorDid: 'did:key:z6MkDemoGuest2', authorName: 'Elena M.', subjectDid: 'did:key:z6MkDemoHost2', subjectName: 'Tomás R.', direction: 'guest_to_host', body: 'An incredible experience. Tomás is a generous teacher and the farm is a paradise. I learned so much about permaculture in one week. The food was amazing — all from the garden.', rating: 5, tags: ['welcoming', 'generous', 'great_conversation', 'helpful'], visibility: 'public', trustWeight: 0.85, createdAt: now - 86400000 * 6, }; const endorsement2 = crypto.randomUUID(); d.endorsements[endorsement2] = { id: endorsement2, stayId: stay2, listingId: listing2, authorDid: 'did:key:z6MkDemoHost2', authorName: 'Tomás R.', subjectDid: 'did:key:z6MkDemoGuest2', subjectName: 'Elena M.', direction: 'host_to_guest', body: 'Elena was a wonderful helper. She arrived with energy and curiosity, respected the land, and left the compost system in better shape than she found it. Welcome back anytime.', rating: 5, tags: ['respectful', 'helpful', 'clean', 'great_conversation'], visibility: 'public', trustWeight: 0.8, createdAt: now - 86400000 * 6, }; }); console.log("[rBnb] Demo data seeded: 6 listings, 3 stay requests, 2 endorsements"); } // ── API: Listings ── routes.get("/api/listings", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const { type, economy, active, search } = c.req.query(); const doc = ensureDoc(dataSpace); let listings = Object.values(doc.listings); if (type) listings = listings.filter(l => l.type === type); if (economy) listings = listings.filter(l => l.economy === economy); if (active !== undefined) listings = listings.filter(l => l.isActive === (active === 'true')); if (search) { const term = search.toLowerCase(); listings = listings.filter(l => l.title.toLowerCase().includes(term) || l.description.toLowerCase().includes(term) || l.locationName.toLowerCase().includes(term) ); } listings.sort((a, b) => b.createdAt - a.createdAt); const rows = listings.map(listingToRow); return c.json({ count: rows.length, results: rows }); }); routes.post("/api/listings", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const body = await c.req.json(); if (!body.title?.trim()) return c.json({ error: "Title required" }, 400); const docId = bnbDocId(dataSpace); ensureDoc(dataSpace); const listingId = crypto.randomUUID(); const now = Date.now(); _syncServer!.changeDoc(docId, `create listing ${listingId}`, (d) => { d.listings[listingId] = { id: listingId, hostDid: body.host_did || '', hostName: body.host_name || 'Anonymous', title: body.title.trim(), description: body.description || '', type: body.type || 'room', economy: body.economy || d.config.defaultEconomy, suggestedAmount: body.suggested_amount ?? null, currency: body.currency ?? null, slidingMin: body.sliding_min ?? null, slidingMax: body.sliding_max ?? null, exchangeDescription: body.exchange_description ?? null, locationName: body.location_name || '', locationLat: body.location_lat ?? null, locationLng: body.location_lng ?? null, locationGranularity: body.location_granularity ?? null, guestCapacity: body.guest_capacity || 1, bedroomCount: body.bedroom_count ?? null, bedCount: body.bed_count ?? null, bathroomCount: body.bathroom_count ?? null, amenities: body.amenities || [], houseRules: body.house_rules || [], photos: body.photos || [], coverPhoto: body.cover_photo ?? null, trustThreshold: body.trust_threshold ?? d.config.defaultTrustThreshold, instantAccept: body.instant_accept ?? false, isActive: true, createdAt: now, updatedAt: now, }; }); const updated = _syncServer!.getDoc(docId)!; return c.json(listingToRow(updated.listings[listingId]), 201); }); routes.get("/api/listings/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const doc = ensureDoc(dataSpace); const listing = doc.listings[id]; if (!listing) return c.json({ error: "Listing not found" }, 404); return c.json(listingToRow(listing)); }); routes.patch("/api/listings/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const body = await c.req.json(); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.listings[id]) return c.json({ error: "Not found" }, 404); const fieldMap: Record = { title: 'title', description: 'description', type: 'type', economy: 'economy', suggested_amount: 'suggestedAmount', currency: 'currency', sliding_min: 'slidingMin', sliding_max: 'slidingMax', exchange_description: 'exchangeDescription', location_name: 'locationName', location_lat: 'locationLat', location_lng: 'locationLng', guest_capacity: 'guestCapacity', bedroom_count: 'bedroomCount', bed_count: 'bedCount', bathroom_count: 'bathroomCount', amenities: 'amenities', house_rules: 'houseRules', photos: 'photos', cover_photo: 'coverPhoto', trust_threshold: 'trustThreshold', instant_accept: 'instantAccept', is_active: 'isActive', }; const updates: Array<{ field: keyof Listing; value: any }> = []; for (const [bodyKey, docField] of Object.entries(fieldMap)) { if (body[bodyKey] !== undefined) { updates.push({ field: docField, value: body[bodyKey] }); } } if (updates.length === 0) return c.json({ error: "No fields" }, 400); _syncServer!.changeDoc(docId, `update listing ${id}`, (d) => { const l = d.listings[id]; for (const { field, value } of updates) { (l as any)[field] = value; } l.updatedAt = Date.now(); }); const updated = _syncServer!.getDoc(docId)!; return c.json(listingToRow(updated.listings[id])); }); routes.delete("/api/listings/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.listings[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `delete listing ${id}`, (d) => { delete d.listings[id]; // Also clean up availability windows for this listing for (const [aid, aw] of Object.entries(d.availability)) { if (aw.listingId === id) delete d.availability[aid]; } }); return c.json({ ok: true }); }); // ── API: Availability ── routes.get("/api/listings/:id/availability", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const listingId = c.req.param("id"); const { status } = c.req.query(); const doc = ensureDoc(dataSpace); let windows = Object.values(doc.availability).filter(a => a.listingId === listingId); if (status) windows = windows.filter(a => a.status === status); windows.sort((a, b) => a.startDate - b.startDate); return c.json({ count: windows.length, results: windows.map(availabilityToRow) }); }); routes.post("/api/listings/:id/availability", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const listingId = c.req.param("id"); const body = await c.req.json(); if (!body.start_date || !body.end_date) return c.json({ error: "start_date and end_date required" }, 400); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.listings[listingId]) return c.json({ error: "Listing not found" }, 404); const awId = crypto.randomUUID(); _syncServer!.changeDoc(docId, `add availability ${awId}`, (d) => { d.availability[awId] = { id: awId, listingId, startDate: new Date(body.start_date).getTime(), endDate: new Date(body.end_date).getTime(), status: body.status || 'available', notes: body.notes ?? null, createdAt: Date.now(), }; }); const updated = _syncServer!.getDoc(docId)!; return c.json(availabilityToRow(updated.availability[awId]), 201); }); routes.patch("/api/availability/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const body = await c.req.json(); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `update availability ${id}`, (d) => { const aw = d.availability[id]; if (body.start_date) aw.startDate = new Date(body.start_date).getTime(); if (body.end_date) aw.endDate = new Date(body.end_date).getTime(); if (body.status) aw.status = body.status; if (body.notes !== undefined) aw.notes = body.notes; }); const updated = _syncServer!.getDoc(docId)!; return c.json(availabilityToRow(updated.availability[id])); }); routes.delete("/api/availability/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `delete availability ${id}`, (d) => { delete d.availability[id]; }); return c.json({ ok: true }); }); // ── API: Stay Requests ── routes.get("/api/stays", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const { listing_id, status, guest_did, host_did } = c.req.query(); const doc = ensureDoc(dataSpace); let stays = Object.values(doc.stays); if (listing_id) stays = stays.filter(s => s.listingId === listing_id); if (status) stays = stays.filter(s => s.status === status); if (guest_did) stays = stays.filter(s => s.guestDid === guest_did); if (host_did) stays = stays.filter(s => s.hostDid === host_did); stays.sort((a, b) => b.requestedAt - a.requestedAt); return c.json({ count: stays.length, results: stays.map(stayToRow) }); }); routes.post("/api/stays", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const body = await c.req.json(); if (!body.listing_id || !body.check_in || !body.check_out) { return c.json({ error: "listing_id, check_in, and check_out required" }, 400); } const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); const listing = doc.listings[body.listing_id]; if (!listing) return c.json({ error: "Listing not found" }, 404); const stayId = crypto.randomUUID(); const now = Date.now(); // Check trust threshold for auto-accept const guestTrustScore = body.guest_trust_score ?? 0; const autoAccept = listing.instantAccept && listing.trustThreshold !== null && guestTrustScore >= listing.trustThreshold; const initialMessage: StayMessage | null = body.message ? { id: crypto.randomUUID(), senderDid: body.guest_did || '', senderName: body.guest_name || 'Guest', body: body.message, sentAt: now, } : null; _syncServer!.changeDoc(docId, `create stay ${stayId}`, (d) => { d.stays[stayId] = { id: stayId, listingId: body.listing_id, guestDid: body.guest_did || '', guestName: body.guest_name || 'Guest', hostDid: listing.hostDid, checkIn: new Date(body.check_in).getTime(), checkOut: new Date(body.check_out).getTime(), guestCount: body.guest_count || 1, status: autoAccept ? 'accepted' : 'pending', messages: initialMessage ? [initialMessage] : [], offeredAmount: body.offered_amount ?? null, offeredCurrency: body.offered_currency ?? null, offeredExchange: body.offered_exchange ?? null, requestedAt: now, respondedAt: autoAccept ? now : null, completedAt: null, cancelledAt: null, }; }); const updated = _syncServer!.getDoc(docId)!; return c.json(stayToRow(updated.stays[stayId]), 201); }); routes.get("/api/stays/:id", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const doc = ensureDoc(dataSpace); const stay = doc.stays[id]; if (!stay) return c.json({ error: "Stay not found" }, 404); return c.json(stayToRow(stay)); }); // ── Stay status transitions ── function stayTransition(statusTarget: StayStatus, timestampField: 'respondedAt' | 'completedAt' | 'cancelledAt') { return async (c: any) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.stays[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `${statusTarget} stay ${id}`, (d) => { d.stays[id].status = statusTarget; (d.stays[id] as any)[timestampField] = Date.now(); }); const updated = _syncServer!.getDoc(docId)!; return c.json(stayToRow(updated.stays[id])); }; } routes.post("/api/stays/:id/accept", stayTransition('accepted', 'respondedAt')); routes.post("/api/stays/:id/decline", stayTransition('declined', 'respondedAt')); routes.post("/api/stays/:id/cancel", stayTransition('cancelled', 'cancelledAt')); routes.post("/api/stays/:id/complete", stayTransition('completed', 'completedAt')); // ── Stay messages ── routes.post("/api/stays/:id/messages", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const id = c.req.param("id"); const body = await c.req.json(); if (!body.body?.trim()) return c.json({ error: "Message body required" }, 400); const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); if (!doc.stays[id]) return c.json({ error: "Stay not found" }, 404); const msgId = crypto.randomUUID(); _syncServer!.changeDoc(docId, `add message to stay ${id}`, (d) => { d.stays[id].messages.push({ id: msgId, senderDid: body.sender_did || '', senderName: body.sender_name || 'Anonymous', body: body.body.trim(), sentAt: Date.now(), }); }); const updated = _syncServer!.getDoc(docId)!; return c.json(stayToRow(updated.stays[id])); }); // ── API: Endorsements ── routes.get("/api/endorsements", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const { listing_id, subject_did, direction } = c.req.query(); const doc = ensureDoc(dataSpace); let endorsements = Object.values(doc.endorsements); if (listing_id) endorsements = endorsements.filter(e => e.listingId === listing_id); if (subject_did) endorsements = endorsements.filter(e => e.subjectDid === subject_did); if (direction) endorsements = endorsements.filter(e => e.direction === direction); endorsements.sort((a, b) => b.createdAt - a.createdAt); return c.json({ count: endorsements.length, results: endorsements.map(endorsementToRow) }); }); routes.post("/api/endorsements", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const body = await c.req.json(); if (!body.stay_id || !body.body?.trim()) { return c.json({ error: "stay_id and body required" }, 400); } const docId = bnbDocId(dataSpace); const doc = ensureDoc(dataSpace); const stay = doc.stays[body.stay_id]; if (!stay) return c.json({ error: "Stay not found" }, 404); if (stay.status !== 'completed' && stay.status !== 'endorsed') { return c.json({ error: "Can only endorse completed stays" }, 400); } const endorsementId = crypto.randomUUID(); const now = Date.now(); _syncServer!.changeDoc(docId, `create endorsement ${endorsementId}`, (d) => { d.endorsements[endorsementId] = { id: endorsementId, stayId: body.stay_id, listingId: stay.listingId, authorDid: body.author_did || '', authorName: body.author_name || 'Anonymous', subjectDid: body.subject_did || '', subjectName: body.subject_name || '', direction: body.direction || 'guest_to_host', body: body.body.trim(), rating: body.rating ?? null, tags: body.tags || [], visibility: body.visibility || 'public', trustWeight: body.trust_weight ?? 0.5, createdAt: now, }; // Update stay status to endorsed if (d.stays[body.stay_id].status === 'completed') { d.stays[body.stay_id].status = 'endorsed'; } }); const updated = _syncServer!.getDoc(docId)!; return c.json(endorsementToRow(updated.endorsements[endorsementId]), 201); }); routes.get("/api/endorsements/summary/:did", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const did = c.req.param("did"); const doc = ensureDoc(dataSpace); const endorsements = Object.values(doc.endorsements).filter(e => e.subjectDid === did); const asHost = endorsements.filter(e => e.direction === 'guest_to_host'); const asGuest = endorsements.filter(e => e.direction === 'host_to_guest'); const avgRating = (list: Endorsement[]) => { const rated = list.filter(e => e.rating !== null); return rated.length ? rated.reduce((sum, e) => sum + e.rating!, 0) / rated.length : null; }; const tagCounts = (list: Endorsement[]) => { const counts: Record = {}; for (const e of list) { for (const t of e.tags) counts[t] = (counts[t] || 0) + 1; } return counts; }; return c.json({ did, total_endorsements: endorsements.length, as_host: { count: asHost.length, avg_rating: avgRating(asHost), tags: tagCounts(asHost), avg_trust_weight: asHost.length ? asHost.reduce((s, e) => s + e.trustWeight, 0) / asHost.length : 0, }, as_guest: { count: asGuest.length, avg_rating: avgRating(asGuest), tags: tagCounts(asGuest), avg_trust_weight: asGuest.length ? asGuest.reduce((s, e) => s + e.trustWeight, 0) / asGuest.length : 0, }, }); }); // ── API: Search ── routes.get("/api/search", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const { q, type, economy, guests, lat, lng, radius, check_in, check_out } = c.req.query(); const doc = ensureDoc(dataSpace); let listings = Object.values(doc.listings).filter(l => l.isActive); // Text search if (q) { const term = q.toLowerCase(); listings = listings.filter(l => l.title.toLowerCase().includes(term) || l.description.toLowerCase().includes(term) || l.locationName.toLowerCase().includes(term) || l.hostName.toLowerCase().includes(term) ); } // Type filter if (type) listings = listings.filter(l => l.type === type); // Economy filter if (economy) listings = listings.filter(l => l.economy === economy); // Guest count if (guests) { const g = parseInt(guests); listings = listings.filter(l => l.guestCapacity >= g); } // Location proximity (simple distance filter) if (lat && lng && radius) { const cLat = parseFloat(lat); const cLng = parseFloat(lng); const r = parseFloat(radius); // km listings = listings.filter(l => { if (!l.locationLat || !l.locationLng) return false; // Haversine approximation const dLat = (l.locationLat - cLat) * Math.PI / 180; const dLng = (l.locationLng - cLng) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(cLat * Math.PI / 180) * Math.cos(l.locationLat * Math.PI / 180) * Math.sin(dLng / 2) ** 2; const dist = 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return dist <= r; }); } // Date availability check if (check_in && check_out) { const ciMs = new Date(check_in).getTime(); const coMs = new Date(check_out).getTime(); const availability = Object.values(doc.availability); listings = listings.filter(l => { const windows = availability.filter(a => a.listingId === l.id && a.status === 'available' ); // At least one available window must cover the requested range return windows.some(w => w.startDate <= ciMs && w.endDate >= coMs); }); } const rows = listings.map(listingToRow); return c.json({ count: rows.length, results: rows }); }); // ── API: Stats ── routes.get("/api/stats", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); const listings = Object.values(doc.listings); const stays = Object.values(doc.stays); const endorsements = Object.values(doc.endorsements); return c.json({ listings: listings.length, active_listings: listings.filter(l => l.isActive).length, stays: stays.length, pending_stays: stays.filter(s => s.status === 'pending').length, completed_stays: stays.filter(s => s.status === 'completed' || s.status === 'endorsed').length, endorsements: endorsements.length, economy_breakdown: { gift: listings.filter(l => l.economy === 'gift').length, suggested: listings.filter(l => l.economy === 'suggested').length, fixed: listings.filter(l => l.economy === 'fixed').length, sliding_scale: listings.filter(l => l.economy === 'sliding_scale').length, exchange: listings.filter(l => l.economy === 'exchange').length, }, }); }); // ── API: Config ── routes.get("/api/config", async (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const doc = ensureDoc(dataSpace); return c.json(doc.config); }); routes.patch("/api/config", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const body = await c.req.json(); const docId = bnbDocId(dataSpace); ensureDoc(dataSpace); _syncServer!.changeDoc(docId, 'update config', (d) => { if (body.default_economy) d.config.defaultEconomy = body.default_economy; if (body.default_trust_threshold !== undefined) d.config.defaultTrustThreshold = body.default_trust_threshold; if (body.amenity_catalog) d.config.amenityCatalog = body.amenity_catalog; if (body.house_rule_catalog) d.config.houseRuleCatalog = body.house_rule_catalog; if (body.endorsement_tag_catalog) d.config.endorsementTagCatalog = body.endorsement_tag_catalog; if (body.require_endorsement !== undefined) d.config.requireEndorsement = body.require_endorsement; if (body.max_stay_days !== undefined) d.config.maxStayDays = body.max_stay_days; }); const updated = _syncServer!.getDoc(docId)!; return c.json(updated.config); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Hospitality | rSpace`, moduleId: "rbnb", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ` `, })); }); // ── Module export ── export const bnbModule: RSpaceModule = { id: "rbnb", name: "rBnb", icon: "\u{1F3E0}", description: "Community hospitality — trust-based space sharing and couch surfing", scoping: { defaultScope: 'space', userConfigurable: true }, docSchemas: [{ pattern: '{space}:bnb:listings', description: 'Listings, stays, endorsements', init: bnbSchema.init }], routes, standaloneDomain: "rbnb.online", landingPage: renderLanding, seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty("demo"); }, feeds: [ { id: "listings", name: "Listings", kind: "data", description: "Hospitality listings with location, capacity, and economy model", filterable: true, }, { id: "stays", name: "Stays", kind: "data", description: "Stay requests and their status (pending, accepted, completed)", }, { id: "endorsements", name: "Endorsements", kind: "trust", description: "Trust-based endorsements from completed stays, feeds rNetwork", }, { id: "hospitality-value", name: "Hospitality Value", kind: "economic", description: "Economic value of stays (contributions, exchanges, gifts)", }, ], acceptsFeeds: ["data", "trust", "economic"], outputPaths: [ { path: "listings", name: "Listings", icon: "\u{1F3E0}", description: "Community hospitality listings" }, { path: "stays", name: "Stays", icon: "\u{1F6CC}", description: "Stay requests and history" }, { path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from stays" }, ], };