Farholm — farholm.com

Farholm is an Eleventy site deployed on Cloudflare Workers. The public marketing site is static, whilst the client itinerary portal is dynamic and backed by Cloudflare KV.

Last major update: June 2026.

Deployment

GitHub repo → Cloudflare Workers Builds → live at farholm.com.

Every commit pushed to main triggers a Cloudflare build/deploy, usually within a couple of minutes.

Gate the deploy on the checks. Set the Cloudflare Workers Builds command to:

npm run verify

verify runs npm ci && npm run check && npm test && npm run build — so if a syntax check or a Worker integration test fails, the build exits non-zero and Cloudflare does not deploy. (For belt-and-suspenders, also protect main on GitHub and make the CI workflow a required status check on pull requests.)

The generated _site/ folder is deployed. Do not edit _site/ by hand.

Local preview

npm install
npm run serve

This starts the Eleventy dev server, usually at http://localhost:8080.

Checks & tests

npm run check   # syntax: Worker + browser scripts + inline template scripts
npm test        # integration: loads the real Worker module against fake KV/R2

npm test (Node's built-in runner, test/worker.test.mjs) exercises the real worker.js through its module boundary with in-memory bindings: public-lookup validation/host/minimization, fail-closed admin auth + Access-JWT verification (valid/tampered/expired/wrong-email), save collision + optimistic concurrency, soft-delete/purge, snapshot/export, readiness, and security headers. CI (.github/workflows/ci.yml) runs build + check + test on every push and PR to main; the deploy gate (npm run verify) runs the same set.

Core file map

PAGES
  index.njk
  services.njk
  about.njk
  why-us.njk
  contact.njk
  expedition-cruises.njk
  travels.njk
  accessibility.njk
  privacy.njk
  cookies.njk
  404.njk
  trip.njk                 Client itinerary lookup page
  cruise.njk               Cruise booking portal page
  brand.njk                Internal brand kit at /brand/ (noindex)

PORTAL / WORKER
  worker.js                Cloudflare Worker routes, APIs, KV access, admin auth
  admin.html               Booking admin UI shell served by the Worker at /admin
  js/admin.js              Admin itinerary editor logic (loaded by admin.html)
  js/itinerary-schema.js   Shared itinerary segment schema for admin + client UI

SHARED SITE STRUCTURE
  _includes/base.njk       Shared page shell, head, analytics, OG/Twitter cards
  _includes/nav.njk        Main navigation
  _includes/footer.njk     Footer
  _includes/schema/        JSON-LD snippets
  _data/site.json          Phone, email, licence numbers, social URLs, site URL
  _data/cruises.json       Cruise portal line/link data
  sitemap.njk              Sitemap generator

CONFIG / BUILD
  eleventy.config.js
  package.json
  wrangler.jsonc
  scripts/check.mjs        Syntax checks (npm run check)
  scripts/decrypt-backup.mjs  Offline decrypt for encrypted exports
  test/worker.test.mjs     Worker integration tests (npm test)
  .github/workflows/ci.yml CI: build + check + test on every push / PR
  _site/                   Generated build output, gitignored

ASSETS
  css/styles.css
  js/site.js
  images/
  images/logo/             Standalone mark SVGs (primary / on-dark / mono)
  favicon.svg
  robots.txt

Public site notes

The public site lives at farholm.com and www.farholm.com. farholmtravel.com redirects to farholm.com via Cloudflare Redirect Rules.

The public site includes:

Cruise booking portal — cruise.farholm.com

cruise.njk builds to /cruise/ and can be served at cruise.farholm.com with Cloudflare routing/rewrite rules.

Cruise line data lives in:

_data/cruises.json

To add a cruise line, add one entry with:

{
  "name": "Cruise Line",
  "tagline": "Short description",
  "url": "personal booking URL"
}

The page is intentionally noindex.

Client itinerary portal — trip.farholm.com

The itinerary portal is dynamic. Clients enter a booking reference and lookup surname to view their itinerary.

Main pieces:

trip.njk                 Client lookup/view page
admin.html               Booking admin UI
worker.js                APIs, auth, KV reads/writes
js/itinerary-schema.js   Shared segment schema
BOOKINGS KV              Stores booking JSON records

Domains

The portal is intended to run at:

trip.farholm.com

/admin is only served on trip.farholm.com or localhost. It is not served from farholm.com or the workers.dev URL.

KV data

Bookings live in Cloudflare KV with keys like:

booking:FH-7K2P9

Each value is a JSON booking:

{
  "reference": "FH-7K2P9",
  "lastName": "Smith",
  "travelerName": "John & Jane Smith",
  "title": "London & Paris — May 2027",
  "summary": "A week across two cities.",
  "segments": [
    {
      "type": "flight",
      "airline": "British Airways",
      "flightNumber": "BA292",
      "from": "Pittsburgh (PIT)",
      "to": "London Heathrow (LHR)",
      "start": "2027-05-03T18:25",
      "end": "2027-05-04T07:40",
      "cabin": "Club World",
      "seat": "12A, 12B",
      "confirmation": "BA77X2",
      "status": "Confirmed",
      "cost": 3120,
      "currency": "USD",
      "notes": "Times are local to each airport."
    },
    {
      "type": "lodging",
      "hotel": "The Savoy",
      "roomType": "Deluxe King",
      "address": "Strand, London",
      "website": "https://www.thesavoylondon.com/",
      "start": "2027-05-04",
      "end": "2027-05-07",
      "cost": 1840,
      "currency": "GBP"
    }
  ]
}

The admin form writes this JSON for you. Manual KV editing is possible but should not be the normal workflow.

Segment schema

Segment fields are defined in one place:

js/itinerary-schema.js

The admin and client itinerary both use this schema. To add a field or segment type, update the schema rather than editing both UIs separately.

Supported segment types currently include:

flight
lodging
cruise
car_rental
rail
ferry
transportation
restaurant
activity
tour
concert
theater
parking
meeting
directions
map
note

Common fields include start, end, cost, currency, status, phone, confirmation, and notes. Type-specific fields are driven by the schema.

Admin UI

Admin URL:

https://trip.farholm.com/admin

The admin is used to create, edit, delete, and preview bookings. It includes:

The admin writes directly to the BOOKINGS KV namespace.

Admin access control

Admin access is protected in layers:

  1. The Worker only serves /admin and /api/admin/* on trip.farholm.com (or on localhost during wrangler dev, and only when DEV_ADMIN_BYPASS=1 is set in .dev.vars — that flag never ships to production).
  2. Cloudflare Access fronts those routes and authenticates the user.
  3. The Worker independently verifies the Cloudflare Access JWT — signature (against the team JWKS), aud, and exp — and checks the email matches ADMIN_EMAIL. This means a forged cf-access-* header cannot grant admin even if the Access app were misconfigured.

Create a Cloudflare Access application for:

trip.farholm.com/admin
trip.farholm.com/api/admin/*

Policy: allow the configured admin email.

Set these in wrangler.jsonc vars:

ADMIN_EMAIL          Expected admin login address (not a secret)
ACCESS_TEAM_DOMAIN   Zero Trust team domain, WITHOUT https:// (e.g.
                     square-rain-e843.cloudflareaccess.com) — the Worker
                     prepends https:// when fetching the JWKS
ACCESS_AUD           Application Audience (AUD) tag from the Access app

If ACCESS_TEAM_DOMAIN + ACCESS_AUD are unset, the Worker falls back to trusting the cf-access-* headers, which is only safe behind a correctly configured Access policy. Setting both is strongly recommended. API keys should always be secrets.

Demo booking

The Worker seeds a demo booking into KV if it is missing:

booking:FH-9QXM4K7P

Client lookup:

Reference: FH-9QXM4K7P
Surname: Demo

(The demo reference was changed from the old short FH-DEMO when references moved to cryptographically random codes. Any leftover booking:FH-DEMO key from a previous deploy is removed automatically the next time the admin list loads.)

The demo appears in admin because it is a real KV booking. If edited and saved in admin, the edited version persists. The Worker only creates the default demo when the KV key does not already exist. If the demo is deleted, it will be recreated the next time the admin list or demo lookup touches it.

Public lookup route

POST /api/lookup

Request body:

{
  "reference": "FH-7K2P9",
  "lastName": "Smith"
}

The lookup returns 404 both for missing references and surname mismatches so booking references cannot be probed. The route is also rate limited per IP via a Cloudflare rate-limiting rule (see the setup checklist).

Admin API routes

All admin routes require Cloudflare Access auth and only run on trip.farholm.com or localhost.

GET  /api/admin/list
GET  /api/admin/get?reference=FH-7K2P9
POST /api/admin/save
POST /api/admin/delete                      Soft delete — moves record to trash: (90-day TTL)
POST /api/admin/purge                       Permanent erasure — removes live + trash copies
POST /api/admin/reset-demo                  Restore the demo booking to the built-in sample
POST /api/admin/snapshot                    Write a backup snapshot to R2 now
GET  /api/admin/export                       All bookings as JSON (admin encrypts + stores off-Cloudflare)
GET  /api/admin/place?q=Hotel%20Name
GET  /api/admin/flight?number=BA217&date=2026-06-15
POST /api/admin/attach?reference=FH-7K2P9    Upload a document (multipart, field "file") to a saved booking
GET  /api/admin/attachments?reference=FH-…   List a booking's attachments
GET  /api/admin/attachment?reference=…&key=… Fetch one attachment (admin download / preview)
POST /api/admin/attach/delete               Remove an attachment (R2 object + record entry)

The client-facing download is not an admin route — it is gated the same way as /api/lookup and only runs on the portal host:

POST /api/attachment   { reference, lastName, key }

It re-verifies the reference + surname against the booking and that the key is one of that booking's attachments before streaming the file, so a client can only ever fetch their own documents.

Booking attachments

Documents (e-tickets, vouchers, confirmations) attach to a booking and live in a separate R2 bucket, farholm-attachments (binding DOCS), at att/<REF>/<uuid>-<filename>. They are served only through the Worker — the bucket is never public. Uploads are capped at 10 MB and limited to PDF and common image types (JPEG/PNG/GIF/WebP/HEIC).

Per DATA.md, attachments must never contain passports/ID numbers, payment-card data, account passwords, or medical details — keep only what's needed to deliver the itinerary. Manage them from the Attachments panel in the booking admin (visible once a booking is saved); clients download them from the Documents section of their itinerary.

Backups

Bookings live in a single KV namespace, so the Worker keeps two safety nets:

Restore a snapshot by downloading the object from the farholm-backups bucket and re-saving the bookings it contains. (See RUNBOOK.md for the full restore/incident procedures.)

Off-Cloudflare encrypted export. Because KV and its R2 backups both live in the Cloudflare account, periodically click Export backup in the admin: it downloads a passphrase-encrypted (AES-256-GCM) JSON of all bookings to keep on your own storage. Read it back offline with:

node scripts/decrypt-backup.mjs farholm-backup-YYYY-MM-DD.json > bookings.json

Data retention & erasure

This is the documented retention policy; honour data-subject erasure requests by using Erase permanently and confirming the ≤90-day snapshot expiry window.

Auto-fill integrations

The admin can use external APIs. Keep these as Cloudflare secrets, never committed files.

GOOGLE_PLACES_KEY   Google Places lookup
AERODATABOX_KEY     AeroDataBox via RapidAPI flight lookup

Set secrets in Cloudflare:

Workers & Pages → farholm-site → Settings → Variables and Secrets → Add → Secret

or locally:

npx wrangler secret put GOOGLE_PLACES_KEY
npx wrangler secret put AERODATABOX_KEY

For local dev, .dev.vars may be used and must stay gitignored.

Cloudflare setup checklist

Required bindings/config:

ASSETS             Static build output
BOOKINGS           KV namespace binding
BACKUPS            R2 bucket binding (farholm-backups) — daily snapshots
DOCS               R2 bucket binding (farholm-attachments) — booking documents
ADMIN_EMAIL        Expected Cloudflare Access email
ACCESS_TEAM_DOMAIN Zero Trust team domain (no https://) — enables JWT verification
ACCESS_AUD         Access application AUD tag — enables JWT verification
GOOGLE_PLACES_KEY  Secret — Google Places lookup
AERODATABOX_KEY    Secret — AeroDataBox flight lookup

A cron trigger (triggers.crons in wrangler.jsonc, 06:00 UTC) drives the daily backup snapshot.

Recommended rate-limiting rule (Security → WAF → Rate limiting rules):

Expression: http.request.uri.path eq "/api/lookup" and http.request.method eq "POST"
Rate:       5 requests / 10 seconds per IP   (10s is the non-Enterprise minimum)
Action:     Block

Required custom domains/routes:

farholm.com
www.farholm.com
trip.farholm.com
cruise.farholm.com  optional clean cruise portal host

Required Access app:

trip.farholm.com/admin
trip.farholm.com/api/admin/*

Things already configured

Performance & ops notes

Still to do

Compliance & legal

Recurring maintenance

Notes

Only _site/ is deployed publicly. Source files such as this README, _includes/, config, and Worker source should not be public on farholm.com. Still, never commit real client data.

Conversational rule of thumb: if anything is edited on github.com, pull it in GitHub Desktop before the next local edit.