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.
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.
npm install
npm run serve
This starts the Eleventy dev server, usually at http://localhost:8080.
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.
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
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:
_includes/base.njkcontact.njkwrangler.jsonccss/styles.csscruise.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.
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
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.
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 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 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 is protected in layers:
/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).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.
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.
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).
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.
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.
Bookings live in a single KV namespace, so the Worker keeps two safety nets:
POST /api/admin/delete does not hard-delete. It copies the
record to trash:FH-XXXX (90-day KV TTL) and removes the live booking: key.
To restore, read the trash value and save it again through the admin.scheduled() in the
Worker, which writes every booking to the BACKUPS R2 bucket at
snapshots/YYYY-MM-DD.json and prunes snapshots older than 90 days. The
Snapshot now button in the admin (or POST /api/admin/snapshot) forces one
on demand. R2 is separate from KV, so a KV namespace loss is recoverable.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
createdAt / updatedAt
timestamps.trash:<REF> with
a 90-day TTL and can be restored.att/<REF>/ prefix sweep). It cannot remove data already
written to R2 snapshots, but those snapshots age out within ~90 days — so a
full erasure is complete once the newest snapshot taken before the erasure has
expired.farholm-attachments) are deleted on Erase
permanently. A soft delete keeps them (the booking can be restored from
trash); they are only removed on permanent erasure. They are not captured in
the KV snapshots, so erasing them is immediate and complete.This is the documented retention policy; honour data-subject erasure requests by using Erase permanently and confirming the ≤90-day snapshot expiry window.
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.
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/*
G-YTY7B13NC2contact.njkwrangler.jsonc/fonts, no Google Fonts request)worker.jshashedAsset filter) served immutable/long-cache/api/health (200 JSON, unauthenticated)/fonts (SIL OFL). To update one, replace the
woff2 and adjust the @font-face at the top of css/styles.css.hashedAsset filter
(/css/styles.36299604.css), which emits styles.<hash>.css.
The worker serves hashed files immutable, max-age=1y and other static media
max-age=30d. admin.html is worker-served (not Eleventy) so it uses the
plain unhashed URLs.cfAnalyticsToken in _data/site.json to your token (Cloudflare dashboard →
Web Analytics), or enable automatic injection on the zone. The beacon only
renders when the token is set.https://trip.farholm.com/api/health
(shallow, 200 { ok: true }). For a dependency-aware probe of KV + R2 use
…/api/health?ready=1 (200 when healthy, 503 + {checks} when degraded).www → farholm.com redirect rule.photos/ via eleventy-img). Done where we have honest imagery: homepage
"real experience" gallery, the three services.njk blocks, and the
expedition-cruises.njk Antarctica section (the one Jake has done). Left as
on-brand SVG by design: the Arctic/Galápagos/Patagonia expedition sections
(no authentic photos — a mislabeled place-photo would mislead; revisit when
Jake shoots them or licenses stock), points-and-miles.njk (intentional concept
art, not a place), and the index/trip hero scenes + travels world maps
(signature/functional). cruise.njk has no art placeholder._data/testimonials.json); just add real quotes./advice/ (posts in
blog/*.md → /advice/<slug>/, layout _includes/post.njk, BlogPosting
JSON-LD, auto in sitemap). Five entry-requirement guides shipped (REAL ID,
UK ETA, EES, ETIAS, Canada DUI). Add more by dropping a Markdown file in
blog/ with title / description / excerpt / date front matter.farholm-attachments).
Create the bucket before the first deploy with this binding.href="#" in
source — the live Terms of Service is published/managed in production, so do not
"fix" the placeholder back to a local page.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.