Lodge and campsite booking for Uganda. Next.js 16 + TypeScript on Cloudflare Workers, Neon Postgres, Drizzle. Real availability with a Postgres exclusion constraint, idempotent booking, staff ops console, 144 tests.
# Pearl Trails
*Stays that stay with you.*
Lodges, campsites and experiences across Uganda — the Pearl of Africa.
**Pearl Trails MVP v1.0.** A traveller can discover a stay, request a booking, plan their
trip and return to it. Staff can sign in, review what came in, respond to it, and change
the catalogue — with every consequential action recorded.
There is no payment layer, and the product never implies one. See
Payments.
## Release 7 — Operations Console
The internal tool that makes the product operable, at `/ops`.
```
Staff sign in
↓
Overview requests waiting · arrivals in 7 days · recent activity
↓
Bookings search, filter, page — all in Postgres, all in the URL
↓
Booking traveller, stay, experiences, estimate, internal notes, history
↓
Act confirm · cancel · expire — only the legal moves are offered
```
### Authentication
Staff sign in with an email and password. Sessions are a random 260-bit token in an
`HttpOnly`, `SameSite=Lax`, `Secure` cookie; only a hash of it is stored, and expiry is
enforced in the query rather than in application code. Passwords are PBKDF2-HMAC-SHA256
with a per-password salt.
Accounts are created interactively — there is no seeded default account, because a
committed default credential is a public one:
```bash
npm run staff:create
```
Failed sign-ins are throttled per account rather than per IP: an IP key is defeated by
rotating addresses and punishes shared connections, which in Uganda is the common case.
`docs/decisions/006-staff-authentication.md` records why this is purpose-built rather
than a library, and — importantly — states plainly that **Cloudflare Workers caps PBKDF2
at 100,000 iterations**, which is below current OWASP guidance. That is the weakest part
of the release, and moving to Argon2id via a maintained library is the upgrade path.
### Authorisation
Every `/ops` page and every operations action begins with `requireStaff()`, which returns
the signed-in staff member or red …