Skip to content

thinkgrid-labs/lulan

Repository files navigation

Lulan — Open-source Headless Reservation Infrastructure for Modern Transit.

CI License: Apache-2.0 Rust

Lulan is an open-source, API-first reservation platform for airlines, buses, ferries, rail, and any operator that sells capacity instead of products. It runs in two modes behind one API: a complete standalone booking engine — inventory, pricing, payments, QR ticketing, offline validation, end to end — or an orchestration layer that owns the customer-facing reservation experience and synchronizes confirmed bookings into the operational systems you already run (airline PSS, ferry manifest backend, bus dispatch) through sync connectors.

Built in Rust: segment-aware seat inventory, race-free reservations under high concurrency, event-sourced order lifecycle, a sandboxed WebAssembly pricing engine, and cryptographically signed QR tickets that validate fully offline.

Think of commerce tools, but for seats, cabins, vehicle slots, and cargo holds" — inventory that exists in space and time, not on a shelf.


Table of Contents


Why an open-source transit reservation system?

Most transportation operators still run on legacy reservation software: expensive licenses, proprietary lock-in, monolithic deployments, and booking conflicts the moment demand spikes. General-purpose e-commerce platforms don't help — they assume inventory is static. Transit inventory isn't:

A ─── B ─── C ─── D        Seat 12A on one departure:

A → B   Reserved           the same physical seat is sold inventory
B → C   Available          on one journey segment and open capacity
C → D   Reserved           on the next.

Selling a seat means atomically claiming a span of segments on a specific departure, while hundreds of other buyers race you for the same span. Lulan is a reservation engine built for exactly that problem — and it is verified, not just claimed: the invariant harness fires 10,000 simultaneous contenders at 52 seats and records zero double-sells, including with Redis killed mid-run.

Key features

Capability What it means
Segment-aware inventory Seats, cabins, vehicle decks, and cargo pools tracked per journey segment with bitmask occupancy — the PRD's "12A is free B→C but taken A→B" answered in one query.
Race-free booking at scale Redis-backed soft holds + guarded PostgreSQL claims. Zero double-sells at 10k concurrent contenders (chaos-tested).
Event-sourced orders Every order is an append-only event stream (order_created → … → passenger_boarded) with a transactional outbox; replaying events reproduces the read model exactly.
Multi-passenger itineraries One order, N passengers; per-passenger seats and fares, including regulated concession fares (child, senior, disability) that many markets mandate by law.
Pluggable pricing (WASM) Deterministic integer-only fare rules, plus operator-supplied pricing modules run in a fuel-metered, memory-capped WebAssembly sandbox with no host imports — measured at p95 ≈ 443 µs per quote. Native and WASM engines are property-tested to be bit-identical.
Signed quotes Short-TTL HMAC quote tokens: the price shown is the price charged, tamper-proof.
Offline ticket validation Ed25519-signed CBOR QR tickets (~245 bytes). The lulan-validate crate verifies them with no server, no clock assumptions, and compiles to WebAssembly for browser and mobile boarding apps. Signing keys rotate live; gates carry a revocation list so refunded tickets are refused offline too.
Offline boarding sync Gate and crew devices journal scans locally and sync idempotent batches when connectivity returns; duplicate and cloned-QR scans are detected and flagged.
Payments without lock-in The provider is a port described by configuration, not code: a JSON file names a PSP's create/refund/callback endpoints, auth style, field mappings and signature scheme. Stripe ships as a preset; anything else is a file.
Headless & self-hostable JSON REST APIs only — bring your own storefront, kiosk, or POS. One Docker image, PostgreSQL + Redis, no per-booking fees.

How it works

A booking flows through seven stages — two optional, all independently verifiable:

  1. Search & availabilityGET /v1/trips/search returns candidate trips per leg (one-way or round-trip), each with operator, service number, vehicle, schedule, and span-aware seat/pool availability. GET /v1/trips/{id}/availability drills into the seat map, including which seats other sessions currently hold.
  2. Hold (optional)POST /v1/holds soft-holds the selected seats across every leg as one itinerary hold with a countdown (expires_at, operator-configurable). Expired holds auto-release with zero cleanup; buying with an expired hold is a deterministic 409. Holds never gate the sale — claims at order time are the source of truth. Holds are bounded so one session cannot take a fleet off sale: at most 20 seats per request, and a configurable ceiling on how much of a trip may be held at once. Both are safe to be blunt about, because a refused hold is not a refused sale — the claim at order time is authoritative.
  3. Add-ons (optional)GET /v1/ancillaries lists everything the operator sells alongside the fare (baggage, meals, insurance, priority boarding), per-passenger or per-order, tied to one leg or the whole itinerary.
  4. QuotePOST /v1/quotes prices the itinerary + add-ons (per passenger type, occupancy, peak day, round-trip and promo discounts) and returns a signed, short-lived quote token locking the full total.
  5. OrderPOST /v1/orders atomically claims every item on every leg for N passengers; a conflict on any leg rolls back everything. One order, one payment — fares and add-ons together.
  6. Pay & ticket — a payment-provider webhook captures payment and auto-issues one Ed25519-signed QR ticket per passenger per leg.
  7. Board — even offline — gate devices verify tickets locally against cached public keys (GET /v1/ticket-keys), then sync their scan journal (POST /v1/scans); the order aggregates to Boarded when the last passenger scans in.

Quick start

Prerequisites: Rust (edition 2024), Docker, just.

git clone https://github.com/thinkgrid-labs/lulan.git
cd lulan
just up      # PostgreSQL 16 + Redis 7 (Docker)
just serve   # migrate, seed a sample multi-stop network, serve on :8080

Full executed walkthrough: QUICKSTART.md runs every step below against a fresh instance with real output, ending in a boarding pass verified with the server switched off. Interactive API reference: GET /docs (Scalar over the committed spec).

Book a ticket end to end:

# 1. Find a departure (sample dataset: a 4-stop ferry line, BTG → … → CEB)
curl "localhost:8080/v1/trips/search?origin=BTG&destination=CEB&departure_date=$(date +%F)"

# 2. Quote a senior + child itinerary (concession fares applied automatically)
curl -X POST localhost:8080/v1/quotes -H 'content-type: application/json' -d '{
  "trip_id": "<trip>",
  "items": [
    {"unit_code": "12C", "origin": "BTG", "destination": "CEB", "passenger_type": "senior"},
    {"unit_code": "12D", "origin": "BTG", "destination": "CEB", "passenger_type": "child"}
  ]}'

# 3. Order with the quote token, pay (fake provider), fetch signed QR tickets.
#    Everything scoped to one order — paying it, cancelling it, reading its
#    tickets — needs that order's retrieval_token (returned at creation),
#    the owning customer's JWT, or an API key. The order id is not a
#    credential. Capture is server-to-server: integration key required.
curl -X POST localhost:8080/v1/orders ...
curl -X POST "localhost:8080/v1/orders/<id>/payment?token=<retrieval_token>"
curl -X POST localhost:8080/v1/payments/webhook \
  -H "x-api-key: $LULAN_BOOTSTRAP_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"payment_intent_id": "<intent>", "status": "succeeded"}'
curl "localhost:8080/v1/orders/<id>/tickets?token=<retrieval_token>"

Run the test suite and the concurrency harness:

just check                 # fmt + clippy + full test suite
just loadgen 10000 0.5     # 10k contenders, 50% via holds — expect 0 double-sells
just loadgen-paced 200 30  # open-loop 200 req/s — honest seat-lock latencies

API overview

Endpoint Purpose
GET /v1/trips/search One-way / round-trip search: candidate trips per leg with schedule + availability
GET /v1/trips/{id}/availability Per-seat / per-pool availability for a journey span
POST /v1/holds Soft-hold a one-way or round-trip seat selection as one itinerary hold (TTL, auto-release)
GET /v1/ancillaries Add-on catalog: baggage, meals, insurance — whatever the operator sells
POST /v1/quotes Itemised quote for fares + add-ons, locked by a signed token
POST /v1/orders Atomic multi-passenger booking (live-priced or quote-token)
POST /v1/orders/{id}/payment Create payment intent (provider port) — order credential required
POST /v1/orders/{id}/cancel Cancel and release claims — order credential required
POST /v1/payments/webhook Provider capture callback → auto-issues tickets (signature-authenticated, or integration key for unsigned providers)
GET /v1/orders/{id}/tickets Ed25519-signed QR ticket tokens
GET /v1/ticket-keys Public keys for offline validators (every key, live and retired)
GET /v1/revocations Tickets to refuse despite a valid signature — refunds, cancellations (validator key)
POST /v1/scans Batched, idempotent boarding-scan sync (validator key)
GET /v1/customers/me/orders Authenticated customer's bookings (IdP JWT)
POST /v1/webhooks Register HMAC-signed webhook endpoints (admin key)
GET /docs Interactive API reference (Scalar)
GET /metrics Prometheus metrics

The full surface is documented in the OpenAPI spec — committed at crates/lulan-api/openapi.json and served live at GET /openapi.json. A typed TypeScript client ships as @lulan/storefront-sdk.

Calling from a browser. Lulan sends no CORS headers by default, so a web storefront must either call through its own backend or have its origin named in LULAN_CORS_ALLOWED_ORIGINS (comma-separated, or *). The API returns passenger data and boarding-pass tokens; which sites may read those is an operator decision, not a default.

Selling stops when the departure does. A trip id bypasses search, so "still for sale?" is enforced where inventory resolves, not where it is listed: quotes, holds, claims and orders all 409 once the trip has left the requested origin (judged per leg — a service mid-journey can still sell its later legs) or once ops has cancelled it. Availability stays readable either way; crew and support still need to see past departures.

What offline validation does and doesn't prove

A valid signature proves a ticket was issued by the operator and not altered. It proves nothing about what happened afterwards, and two things do:

  • Cloning. A copied QR is still a genuine QR. Same-device re-scans are rejected from the device's local seen-set; cross-device duplicates are detected server-side once scan journals sync.
  • Revocation. A refund happens after signing, so no offline check can derive it from the ticket. Devices cache GET /v1/revocations alongside the key set and pass it to verify_ticket_with_revocations.

Both are detection, bounded by how recently a device synced — a gate that has never synced cannot know. We would rather say that than imply cryptography solves it.

Signing keys rotate through POST /v1/admin/ticket-keys/rotate and take effect immediately on every replica. Retired keys stay published, because tickets already in wallets were signed with them; rotation changes what is signed next, so responding to a leaked key means rotating and revoking what it signed.

The private half is encrypted at rest (XChaCha20-Poly1305, keyed by LULAN_TICKET_KEY_ENCRYPTION_KEY), so a database dump — a backup, a stolen replica, an over-broad read grant — no longer contains anything that can forge a boarding pass. Set the variable and restart: existing keys are sealed in place. It buys separation of the database from the signing key, which is the realistic breach; it is not an HSM and does not defend a compromised host, which reads the environment either way.

Losing that key is recoverable, deliberately: public halves stay unencrypted, so issued tickets keep verifying and you only lose the ability to sign new ones — which a rotation fixes.

Bring your own providers

Lulan never owns accounts and never touches card data — identity and payments are ports: the core defines the contract, an adapter plugs your provider in. (Reservation Sync Connectors, planned, follow the same pattern for operational systems.)

Identity provider — customers and staff

The core verifies a bearer JWT from your IdP and keeps only an (issuer, subject) reference. No passwords, resets, sessions, or MFA in Lulan — ever. One port serves both principals: a plain verified JWT is a customer; a JWT that matches an enrolled staff row gains an operator role (admin / ops / support).

# Ships today: HS256 shared-secret JWT (first-party storefront backends)
LULAN_IDP_ISSUER=https://auth.example.com
LULAN_IDP_HS256_SECRET=...            # your IdP's signing secret
LULAN_BOOTSTRAP_ADMIN_STAFF='https://auth.example.com|user-id-of-admin'
Use case How it maps
Ferry line with a Next.js storefront using Supabase/Firebase auth Storefront session JWT goes straight to Lulan as the customer token — bookings attach to the customer, GET /v1/customers/me/orders lists them
Bus company on Auth0 / Clerk / Keycloak / Entra Set LULAN_IDP_JWKS_URL — one JWKS adapter covers every IdP that publishes a JWK Set. Keys refresh in the background, so no IdP call sits on the auth path
Walk-up / kiosk sales, no accounts at all Skip the IdP entirely: guest checkout is first-class — guest_contact + an HMAC retrieval token (magic link) per order
Back-office staff signing into the admin app Same IdP login; an admin enrols their identity via POST /v1/admin/staff with a role — every action they take is audited by name

Payment provider — configuration, not code

Most payment APIs are the same three shapes wearing different names: POST somewhere to create an intent, POST somewhere to refund it, receive an HMAC-signed callback. So a provider is described, not implemented — a JSON file, no Rust, no rebuild, no fork. The same idea as pricing modules: a runtime artifact the operator supplies.

# A built-in preset — this is the entire Stripe integration
LULAN_PAYMENT_PROVIDER=stripe
LULAN_PAYMENT_SECRET=sk_live_…
LULAN_PAYMENT_WEBHOOK_SECRET=whsec_…

# Anything else — describe it once
LULAN_PAYMENT_PROVIDER=/etc/lulan/my-psp.json

A description says where to POST, how to authenticate (bearer / basic / custom header), whether bodies are JSON or form-encoded, which JSON pointers hold the intent id and client secret, and how callbacks are signed (SHA-256/512, hex/base64, raw or Stripe-style headers, replay tolerance) — plus which of the provider's event names mean captured and failed. Fully commented starter: deploy/payment-providers/example.json. Stripe ships as a preset specifically to prove the description handles a real, large PSP rather than a toy.

Use case How it maps
Global card payments (Stripe) Built-in preset; supply two secrets. payment_intent.succeeded → order Paid → tickets auto-issue
PH e-wallets — GCash/Maya via PayMongo or Xendit A description file: different URLs, JSON bodies, a raw signature header. No engine change
A bank gateway nobody has heard of Same file. If its callbacks are unsigned, Lulan requires an integration API key on the webhook endpoint instead of trusting an open one
Something genuinely stranger (SOAP, an SDK, a redirect flow) Implement the PaymentProvider trait in Rust — the escape hatch, not the expected path
Cash at a counter / agent network A trusted integration API key confirms payment through the same webhook path — the state machine doesn't care who captured
Trip cancelled by ops, or support refunds a booking Lulan calls refund() before releasing seats and voiding tickets — money moves first, inventory second

Whatever the provider, the engine only ever sees captured or failed: adapters translate, and an adapter cannot report an event it has not authenticated. Verification and interpretation live in the same call on purpose.

With no provider configured Lulan runs FakeProvider, which captures payment without taking money. That is a demo, and it says so at boot.

Payment capture is idempotent (duplicate and out-of-order webhooks are acknowledged, never re-applied), and Idempotency-Key on order creation makes client retries double-booking-proof end to end: the key is claimed before the order is written, so two concurrent retries cannot both book. Keys are scoped to the caller and bound to the request body — one client's key never replays another's order, and reusing a key for a different request is refused rather than answered with an unrelated booking.

Money is recorded in the currency the fare ruleset priced it in, per order. Lulan does not convert between currencies (multi-currency settlement is out of scope for v1); an operator selling in several publishes a ruleset per currency.

Architecture

Lulan architecture: a booking request flows from your storefront over JSON REST into lulan-api (Axum, HTTP/2), through lulan-engine and lulan-pricing, out to PostgreSQL (source of truth, claims and events), Redis (soft holds, cache) and operator-supplied .wasm fare modules; a signed QR ticket then leaves for the gate, where lulan-validate (wasm32) verifies it with zero server dependency.

Same diagram as text
        Your storefront / kiosk / POS / boarding app
                          │
              JSON REST  ·  @lulan/storefront-sdk (TS)
                          │
                ┌─────────▼──────────┐
                │     lulan-api      │  Axum · HTTP/2
                ├────────────────────┤
                │    lulan-engine    │  inventory · orders · tickets
                │   lulan-pricing    │  native + WASM sandbox (wasmtime)
                └─┬───────┬────────┬─┘
                  │       │        │
            PostgreSQL  Redis   *.wasm pricing modules
            (truth +   (holds,  (operator-supplied,
             events)    cache)   no host imports)

   Offline edge:  lulan-validate (wasm32) verifies tickets
                  with zero server dependency.

Abuse control

Search and availability need no credential, so their cost is bounded rather than trusted: reads and writes have separate per-caller budgets (browsing a seat map cannot exhaust the budget needed to book), search pages are capped, and every request has a timeout.

Callers are identified by credential when they have one, otherwise by connection address. X-Forwarded-For is honoured only as far as LULAN_TRUSTED_PROXY_HOPS says there are proxies in front, counting from the right — a client can prepend anything it likes to that header, but it cannot forge the entries its own proxy appends. The default of 0 ignores the header entirely; behind Caddy or an ingress, set 1.

The limiter still fails open: if Redis is unavailable, requests pass. Abuse control must never become the outage.

Benchmarks

Real numbers, adversarial shapes, published in docs/benchmarks.md:

  • 0 double-sells across 10,000 simultaneous contenders on one 52-seat vehicle — repeated with Redis killed mid-run (chaos test).
  • WASM pricing: p50 347 µs / p95 443 µs per quote including per-call instantiation (PRD target < 5 ms), enforced by a CI assertion.
  • Ticket QR payload: ~245 bytes signed (target < 400 bytes for low-error-correction QR).

Project status & roadmap

⚠️ Pre-1.0, active development. APIs may change until the first stable release. Verified working today — the checked items below are implemented and covered by the test suite.

  • Segment-aware inventory engine (seats, pools, span claims)
  • Soft holds + race-free claims (0 double-sells @ 10k contenders)
  • Event-sourced order lifecycle with a configuration-driven payment-provider port (Stripe preset built in)
  • Pricing engine — native + sandboxed WASM modules, signed quotes
  • Multi-passenger orders with passenger-type fares
  • Ed25519 QR ticketing + offline validation (lulan-validate)
  • Offline boarding-scan sync with replay/clone detection
  • Ticket key rotation (live, no restart), signing keys encrypted at rest, and a revocation list gates carry offline
  • Webhooks: HMAC-signed deliveries with durable retries
  • Authentication: API keys + roles, identity-provider port, guest checkout with retrieval tokens
  • Order-scoped authorization — paying, cancelling, reading an order or its tickets each require that order's credential; the id alone is not one
  • Idempotent booking retries — the key is reserved before the write, scoped to the caller and bound to the request body
  • Abuse control: separate read/write rate-limit budgets, callers identified by connection (not a spoofable header), bounded search pages, request timeouts
  • Identity: JWKS (Auth0 / Clerk / Keycloak / Supabase / Firebase) or HS256 shared secret
  • OpenAPI spec (served at /openapi.json) + TypeScript SDK (@lulan/storefront-sdk)
  • Prometheus /metrics, plus OTLP span export behind --features otlp
  • Itineraries: one-way, round-trip & multi-city (one atomic order across legs, round-trip fares)
  • Itinerary holds: one hold across all legs, TTL auto-release, live held-seat map
  • Ancillaries: operator add-on catalog (baggage, meals, insurance) priced into quotes & orders
  • Sale gating: departed and cancelled departures are refused wherever inventory resolves (a trip id bypasses search; sellability does not)
  • Per-currency orders — money is recorded in the currency the active fare ruleset priced it in
  • Hold stampede control: per-request seat cap + a configurable per-trip hold ceiling, neither of which can gate a sale
  • Opt-in CORS so a browser storefront can call the API directly
  • Production deploys: Compose (external or bundled databases, auto-TLS) + Helm chart
  • GTFS importer — bring your existing schedule feed
  • Open-loop benchmark mode (published seat-lock latencies vs the <20 ms target)
  • Admin operations API: staff RBAC (IdP-backed), network & schedule management, fare publishing with rollback, manifests, refunds — with @lulan/admin-sdk
  • Reference Next.js storefront + React Native boarding-crew app
  • @lulan/validate npm package — the validator compiled to WASM (browser / React Native / Node), tested against real engine vectors
  • Reservation Sync Connectors — first-class orchestrated mode (PSS / manifest / dispatch sync, external-ref mapping)

Use cases

Lulan models any business that reserves capacity over space and time: regional and low-cost airlines, intercity and commuter bus lines, ferries and RoRo vessels, rail and metro networks, shuttle and van fleets, cargo and parcel space and vehicle-deck slots

Standalone mode fits operators without a sophisticated backend — provincial bus lines, ferry and tourism operators, shuttles, charters, small regional airlines: Lulan is the whole system, from search to boarding. Orchestrated mode fits enterprises with existing operational platforms: Lulan owns discovery → pricing → cart → payment → confirmed reservation, then a Reservation Sync Connector pushes it into the PSS / manifest system / dispatch backend (planned; today's HMAC-signed webhooks already enable the same integration DIY). Same API and domain model either way — only the connector changes.

Not just ferries — same engine, one domain per deployment

The primitives are domain-agnostic: seats (reserved, fare-classed), pools (capacity sold by the count), and segments (a claim spans [from, to)). A ferry is the many-segment case; a live event is the one-segment case. Nothing in the engine knows the difference.

A concert in an arena maps straight onto them:

Transit concept Concert equivalent
Vehicle / vessel The venue (the arena)
Fare-class seats Reserved sections — VIP, lower box, upper box
Pool capacity General admission (an admission pool: one bearer QR per unit)
Route stops → segments Doors → end — a single segment
A departure (trip) One show night
Signed QR boarding pass The ticket scanned at the gate

lulan-api seed events seeds exactly that arena show — priced in USD — and the same eight API calls from the ferry quickstart sell a VIP seat plus two general-admission tickets and validate them offline at the gate. See QUICKSTART-events.md.

One important boundary: a Lulan deployment serves one domain. The active fare ruleset is global (exactly one at a time), so seed gives you a ferry deployment and seed events gives you a separate concert deployment in its own database — you never run both in one instance. "Not just ferries" means the same engine and API deploy for either; it does not mean one instance sells both. Pooled admission also distinguishes people from freight: general admission and foot passengers issue one boarding pass per unit, while bulk pools (cargo kilograms, vehicle-deck slots) issue none.

Contributing

Lulan is developed in the open and welcomes issues, design discussions, and pull requests. Contribution guidelines and architecture decision records will be published as the project approaches its first release.

License

Apache-2.0 — the whole repository: engine, API, pricing, SDKs, the offline validator, deployment manifests and examples.

One licence, no carve-outs. Embed lulan-validate in a proprietary boarding app, ship @lulan/storefront-sdk in a commercial storefront, run a modified Lulan as a hosted service — all fine, and none of it needs a lawyer's opinion first. The Apache patent grant travels with the code, and its retaliation clause applies to anyone who sues over it.


Keywords: open-source reservation system · headless booking engine · reservation orchestration platform · airline reservation system · bus booking system · ferry reservation software · rail ticketing · seat reservation API · segment inventory · Rust booking engine · offline ticket validation · QR ticketing · WebAssembly pricing

*Lulan aims to be the open-source foundation for capacity reservation worldwide — bringing modern developer tooling to an industry still dominated by legacy software. The name comes from the Filipino word for "to board, to load."