The operating manual for traveller.org — kept current with every publish that changes a process. Sits with the CMS portal behind the admin door.
content change → commit to GitHub → GitHub Actions builds the site (Astro)
├─ recomputes every stat, listing, filter, and home-page count
├─ regenerates llms.txt, llms-full.txt, the sitemap
└─ deploys to STAGING (the test site) → live there in ~2 minutes
you review staging → press Publish (/admin/publish/)
└─ the production branch fast-forwards to staging → the LIVE site rebuilds production branch moves and
nothing more — Cloudflare Pages starts watching it at cutover step 1.)production branch: attributable,
diffable, revertable. Rolling back is one revert on staging plus one more Publish.GITHUB_TOKEN as the Start
import button — the callout below covers setting it up once for both.| Tier | Size | Lives | Serves |
|---|---|---|---|
| Thumbnails | 460 px | repo (public/thumbs/) for legacy galleries; R2 (thumbs/…) for upgraded ones | gallery grids, cards, strips |
| Large (legacy) | 800 px | old InMotion server (hotlinked) | lightbox, for galleries not yet upgraded |
| Large (high-res) | 2048 px | Cloudflare R2 | lightbox, for upgraded galleries |
Each gallery flips from the legacy tier to R2 individually (the
base: "r2" flag on its record) the moment its high-res batch is imported —
a gallery-by-gallery upgrade with no big-bang switch. When a gallery flips, its 800 px
versions simply stop being referenced.
Once a country has any high-res gallery, its remaining legacy galleries are automatically labelled Deprecated · 800px and sorted below the new ones. (The 31 albums that carried that label were pruned on 2026-08-18 at the owner's request, ahead of launch; the mechanism stays, so a future import that deprecates a sibling album labels it the same way.) Country stats (galleries, photographs, travel years) are recomputed from the live gallery list on every build, so they stay correct as batches land.
The R2 bucket is live: traveller-images, public base
pub-d86b555b7dd7462abd871c743192bcd6.r2.dev. Per batch:
<country>/<year>/<name>/ — e.g. vietnam/2009/halong-bay/ — via the bulk upload page (the traveller-upload Worker: passphrase-gated, no file limit, keeps folder structure, accepts JPEG in all three spellings (.jpg .jpeg .jpe), PNG and RAW; source in cloudflare/upload-worker.js) or rclone. The Cloudflare dashboard uploader caps at 100 files and flattens folders.vietnam/2009/halong-bay/Cat Ba/…. Photos arrive place-tagged; the gallery groups itself into city sections.GITHUB_TOKEN is not set up, the button says so and falls back to: tell Claude "batch uploaded: burma/2001".) Either way the workflow runs the same on GitHub's servers: develops any RAW files (.mrw etc., camera white balance; the RAW itself is not kept) → normalizes larges to ≤2048 px → generates 460 px thumbnails into the bucket → updates the gallery + place data → publishes → zips the processed batch into zips/ and emails you the download link (Resend, RESEND_API_KEY secret). The gallery is high-res on the live site at the end, and its legacy 800 px versions stop being used.www.traveller.org/admin*, and workers.dev is a different
front door whose only lock is the passphrase. So the gate defends itself: every wrong
passphrase costs about a second before the refusal arrives, roughly ten wrong tries a
minute from one address triggers a one-minute lockout (during which even the
right passphrase is refused — wait it out, it clears itself), and failed attempts are
visible live at Cloudflare → Workers → traveller-upload → Logs. If you mistype your own
passphrase you will only ever notice the short pause; the connected pages never touch the
budget because correct-key requests do not count against it. The comparison itself is
constant-time, so response timing never hints how close a guess was.R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
R2_ACCOUNT_ID) — never in the repo, never in chat, and Claude's own
sandbox can't reach Cloudflare at all. To rotate: R2 → API Tokens → roll the token,
then update the two GitHub secrets.GITHUB_TOKEN secret — nothing is broken and the uploaded files are safe in
the bucket. Two steps, once, and the button works from then on: (1) at
github.com → Settings → Developer
settings → Personal access tokens → Fine-grained tokens → Generate new token,
name it traveller-upload dispatch, Resource owner brarob100, Repository
access Only select repositories → traveller.org, Repository permissions →
Actions: Read and write and Contents: Read-only (the Publish page's
waiting-list needs to read commits; a token from before 2026-08-18 has only Actions and
makes that page say "github error 403" — edit the token and add Contents), Generate, copy the github_pat_…
value. (2) at
Cloudflare
→ Workers & Pages → traveller-upload → Settings → Variables and Secrets → Add
→ type Secret, name exactly GITHUB_TOKEN, paste the value → Deploy.
No Worker code change is needed — the dispatch route is already deployed. Reload the
upload page and Start import → runs the workflow itself. If the token expires the
button simply reverts to the same fallback message.repo scope (this repo is private, so a classic
token needs it), or a fine-grained token whose Repository access does not include
traveller.org. GitHub deliberately answers 404 rather than 403 for private
repos, so "not found" usually means "not permitted".traveller.org with Actions: Read and write, and replace the Worker secret's
value. Nothing about the uploaded files is affected — they sit in the bucket until an
import runs, and the "tell Claude" fallback always still works.The page prints its version pinned to the bottom-right corner of the window, where
it stays as you scroll — the commit that deployed it plus the date ("uploader
a1b2c3d · 2026-08-11"), stamped automatically at deploy so it can never disagree with what
is actually running. (It used to sit at the foot of the card in a grey too faint to read,
which defeated the point of having it.) When something that was just fixed
still misbehaves, check that stamp first: no stamp at all means a copy from before
2026-08-11, and an older number means an older page. Until the stamp existed the page was
served without cache headers, so browsers could keep an old copy alive across reloads —
and closing the browser does not clear that cache. It is now served no-store: every load fetches the
current page, and a plain reload is always enough from here on.
The one browser that stays stuck is one still holding a copy cached from before
that change — closing the browser does not clear its cache. Escape hatch: open the page
with anything after a ?, e.g.
https://traveller-upload.brarob.workers.dev/?fresh — a URL the cache
has never seen is always fetched, and from then on the plain address behaves too.
Any junk after the ? works, and the page is identical there — passphrase,
galleries and Start import all behave exactly as at the plain address.
After every drop the page prints a receipt of exactly what the browser handed over, grouped by extension — "Received 134 files — 66 × .mrw, 66 × .thm, 2 × .jpg. Uploading 68; skipping 66". Read it whenever a photo seems to be missing: if its extension is not in the received list at all, the browser never delivered the file (see the drag note below); if it is listed but under "skipping", the page declined it and the receipt says why.
One more drag-and-drop edge worth knowing: when dragging from a remote desktop window or some photo-manager apps, the browser receives the files without their folder structure. The page copes — the photos upload as if dropped loose — but place subfolders cannot be detected from such a drag, so if place tags matter, drag from Finder or Explorer, or use the click-to-pick folder picker.
An import emails you (21 Aug 2026) — the review notice. It is not the old completion receipt: it carries the album’s address on the test site, so the decision it exists for can actually be made from a phone. See The review email below.
Film conversions still do not email, suppressed at your request on 11 Aug 2026. Nothing else about them changed: every batch still produces its zip record, the download link is printed in the run log and listed on the zip records page, and any file skipped as unreadable or duplicate is still named in that log. Two other notices deliberately send: the connection test (mode verify with no country — its whole point is to arrive) and the confirmation that an album was deleted, since that one is destructive.
The review notice has its own kind in the code rather than reversing the
11 August switch, because the two are not the same thing. What was switched off was a
receipt for work you had just watched happen. What replaced it is the one thing the run log
cannot give you: the links you need to decide whether to publish. Flipping
NOTIFY_ON_COMPLETE would have brought the film receipts back with it.
Every photo import — from the upload page, from the iPhone Shortcut, from anywhere — finishes by emailing you Ready to review: <album> — N photograph(s). It holds four things:
production
branch, so the difference between what the test site holds and what that branch holds
is the review queue. An album counts as waiting if it is new, if its photographs
changed (a sync or a rotation is worth another look), or if it was hidden or unhidden.
An album marked [unlisted] is listed nowhere on the site, so the link in the email
is the only way to reach it.Send four batches and you get four emails — one per album, because one import run is one album. Each of them carries the full waiting list, so whichever one you open tells you everything there is to review.
If a link is not found yet, the test site has not finished rebuilding —
it takes about a minute after the email. Reload.
The waiting list is read from GitHub over its API rather than with git,
on purpose: the import runs in a shallow checkout that it commits and pushes from moments
later, and a second fetch writes to that checkout. If the API cannot be reached the email
still arrives with the album’s own link, and says the rest could not be worked out.
The upload page’s destination list is read from the published site, not from storage — so a newly imported album (or a newly created country) appears once the site has rebuilt: typically 2–4 minutes after the import commits, plus a few seconds for the deploy to go live. Hit Connect → again to re-read it; the page fetches the list fresh every time rather than reusing a cached copy.
The square country maps are generated by .github/scripts/make_map.py from
Natural Earth world data vendored in the repository (.github/data/) — no
network needed, no external service. A new country’s map is drawn automatically
during its first import. To re-make maps (style changes, a boundary worth updating):
Actions → R2 gallery import → mode map, with country set to a slug, several
separated by |, or all. Each country’s map matching in the
world data is its ne field in src/data/countries.json — the name
Natural Earth uses, e.g. Myanmar for Burma.
Deleting an album that is already gone is a no-op that succeeds — a second click, or a re-run of an old job, reports "already deleted, nothing to do" instead of failing. If the gallery record is gone but files are still in storage (a delete that was interrupted half-way), the run finishes the job rather than shrugging at it. Previously either case ended in a red ✗ reading "nothing deleted", which looks like a fault when the album is exactly as you wanted it.
Deleting a legacy album removes its record and the 460 px thumbnails the repo
keeps for it, and says how many it removed and from where. Those thumbnails are stored
under the album’s folder path rather than its name — 2007-kerala keeps
its thumbnails in india/2007/kerala/… — and until 2026-08-12 the delete looked
under the name, found nothing, and reported "already pruned?" while leaving every file in
place. If a delete ever does report finding no thumbnails, it now names the two paths it
tried, so the message can be checked rather than believed. The full-size scans on the
legacy server are never touched by any of this.
A file that cannot be decoded — a scan that was already corrupt, or an upload cut short part-way — is set aside and named, and the rest of the album imports normally. The run log carries a warning listing each one, and your import email says which files were left out and why. Remedy: re-upload just those photographs (the same filenames replace them) and run the import again to add them. If every file in an album is unreadable the import stops rather than publishing an empty album.
Before this, one bad file aborted the whole batch with
OSError: broken data stream and nothing imported at all — the failure named a
line of Python rather than the photograph responsible.
Every photo is stood upright at import from its EXIF orientation tag. Phones store pixels the way the sensor saw them plus a tag saying which way is up; until 2026-08-12 the pipeline resized those pixels as-is and re-saved without the tag — a portrait iPhone photo would have published sideways, permanently. Now the image is transposed upright before any resize or thumbnail, in every mode (import, rotate, currency). Photos small enough to keep their original bytes keep their EXIF too, and their thumbnails are generated upright.
A thumbnail is addressed by its album, and album slugs nest. The thumb of
country/slug/file.jpg lives at thumbs/country/slug/file.jpg —
album path plus the file's own name, any place folder dropped. That is only correct if the
album named is the one the photograph belongs to, and Sri Lanka has both 2005
and 2005/overview: a key under the second starts with the first as well. Taking
the first record that matched built thumbs/srilanka/2005/…, nothing
answered there, and Sri Lanka's card was an empty frame on the live home page (2026-08-20).
The album is now found by which record lists the photograph, falling back to the
longest matching path — and tests/thumb-keys.test.mjs checks every
thumbnail address in the whole build against the album that holds the file, which is 94,000
of them and the only way this class of fault is visible without opening a page.
Rotation is a rename, and the position survives it. A rotated photo is written
under a new -rN name (immutable caching forbids rewriting the old one) and the
gallery record is patched in place: same index, new name — so nothing about turning a
photograph ever changes where it sits. Three directions since 2026-08-20: left, right and
180, the last added because a half-turn as two quarter-turn runs meant two waits and
re-finding the photo under a new name in between. A rotation request that still names the
pre-rotation file follows the rename chain to the newest -rN sibling rather
than failing, which is what makes a second click safe before the page has reloaded.
HEIC imports and is served as JPEG. Browsers cannot display HEIC, so a
.heic/.heif upload is decoded, stood upright, saved as
.jpg, and the original is deleted once the JPEG is safely written — the same
shape as the RAW path. The upload page also shrinks big photos on the phone before
they upload (togglable): down to 2560px JPEG, which is more than the 2048px the site
serves, so nothing that publishes is lost — and a shrunk HEIC arrives as JPEG already.
Three things stop the same picture appearing twice, and it is worth knowing which catches what:
IMG_1561 (1).JPG, a copy whose
metadata was rewritten, and the full-size original of a photo already shrunk to 2048 px
all collapse onto one copy. The extras are deleted from storage and listed in your import
email. A photo already published is never the copy removed; between two new arrivals the
cleaner, shorter filename wins.The country in a new album’s path has to match a country the site already knows —
northkorea, not north-korea. Type an unfamiliar one and the upload
page stops you, offers the closest match, and fixes the path in one click. Push past it and
the album imports, but has no country page to live on; the build names it in the log and
skips it rather than failing. To rescue one, re-run the import against the right country
with from_slug set to the wrong full path (e.g.
north-korea/2008/kaesong) — the photographs move inside storage, no re-upload.
If the slug is not a typo but a genuinely new country, the same interrupt offers to create it: give it a display name and a region and the import writes the country record before processing the batch. The check consults the site’s full country roster, so a country that exists with no albums yet (journal-only, currency-only) is never mistaken for a new one.
That interrupt is the safety net for a typed path. The ordinary route is to
declare the country up front: the destination builder’s country list ends with
“+ a country the site doesn’t have yet…”, which reveals a
name field and a region select (South Asia, Middle East, Asia-Pacific,
The Americas, Africa, Europe). The name slugifies into the country segment the same way
the gallery name slugifies into the last one, and the pair rides to the pipeline as the
new_country input (region|Display Name) — the same input
the interrupt produces. Declared this way, the interrupt never fires, because the question
it asks has already been answered.
An album filed under the wrong country is not a photo move — the album keeps its slug, its
photographs and its thumbnails, and only the country segment changes. The pipeline’s
move-album mode does that: country/slug name the album,
to_path is the destination country (or country/slug to rename it in
the same move). Every object is copied before anything is deleted, so a run that stops
half-way can simply be run again; place tags, a pinned preview strip, a hero slide and the
country’s card photograph all follow the album, since each is keyed by its path.
It is a merge, not just a move. If the source country is left with no galleries, no
journal entries and no banknotes, its record and locator map are removed — otherwise an empty
country sits in every listing and inflates every “N countries” on the site. A
country that still has a letter or a note keeps its record, and the run says so as a warning
rather than deciding for you. First use: grandcayman/2022 was a Grand Cayman
album that belonged to the Cayman Islands (2026-08-19).
The cleanup page’s Move to… control dispatches the pipeline’s
move-photos mode: larges and thumbnails are copied bucket-side to the
destination prefix and deleted from the source, both gallery records update, source place
tags for the moved files are pruned, and a home cover pointing at a moved photo is
cleared. Nothing is reprocessed and nothing leaves storage. Name collisions get a
-m1/-m2 suffix; a move that would empty the source album is
refused.
Scans of black-and-white prints often publish flat and washed out: the picture never
reaches true black or true white, and the paper or film base tints the whole frame. The
pipeline’s tone mode re-develops them — and always writes the
results to a different album, so the originals survive and the two can be compared
before anything is decided. Pointing it at its own source is refused outright.
Three things happen to each photograph, in order:
soft, normal and strong set how much;
the curve fixes pure black and pure white exactly, so nothing clips.The email reports, per photograph, the span (how much of black-to-white the picture uses, out of 255), the contrast, and the colour cast — before and after. So the decision has numbers next to it, not just an impression. Known limit: this is tone only. It cannot rescue detail a scanner never captured, and it does not sharpen, de-dust or repair scratches.
Run once so far, and declined. The 25 black-and-white frames in
burma/2001/bagan were re-developed at normal strength into a comparison
album in August 2026. The pass did what it claims — span 195→253, contrast
44→60, colour cast 14→0 on average, with no frame made worse — and the
owner looked at both and kept the originals. The comparison album was deleted. Bagan
publishes flat by choice, not by oversight; do not re-run the pass on it without being
asked.
The switch downsizes anything over 2560 px in the browser before it is uploaded. It is on by default on the desktop page as well as the phone, and that is deliberate: nothing anywhere keeps a full-resolution photograph. The bucket's copy is normalised to 2048 px on import, and the batch zip is built from those same developed 2048 px JPEGs — not from the file you sent. So the only thing the switch costs is one extra resample on the way down (2560 px → 2048 px), which is invisible at the size the site serves, and it saves roughly three quarters of the upload.
Three things it never touches: RAW files skip it entirely and upload untouched; EXIF orientation is baked into the pixels as it shrinks, so a shrunk photo cannot publish sideways; and the year read from the photographs happens before any shrinking, on the originals. The one reason to switch it off on a desktop: on a fast wired connection with a large batch, the browser's own resizing can be slower than simply sending the bytes.
The edit-covers mode writes four records: a country’s home-card
photograph into home.json, a gallery’s four-photograph strip into
strips.json, and — when the Cards & strips page sends them —
the home page’s hero carousel list and its featured journal
entries, both into home.json. Unlike
cards and strips the carousel saves whole (its order is the data) and a slide may
come from any gallery; the pipeline still refuses an unknown photograph by name, an
empty list, more than twelve slides, a repeated photograph, or a caption past 200
characters. All of them are merged at the record level at commit time, the
same treatment journal photographs get, so two sessions curating different countries cannot
overwrite each other and a concurrent import cannot drop a pin.
featured is a list of journal entry ids, and saves whole for the
carousel’s reason — the order is the order the panel reads in. The wall that
matters here is the id itself: data.js resolves each one against
journal.json and drops what it cannot find, so an id for a deleted entry
never failed, it published a short row that looked like a design choice. The mode now
refuses an unknown id by number and says it was probably deleted, refuses a repeat, an
empty list and more than twelve. It also refuses a boolean, which Python would otherwise
have read as entry 1 — there is an entry 1, and it is on the home page.
Whether the rows come out whole is enforced one layer up, on the page, where the row
count is on screen beside the buttons that change it.
A strip lives in its own file rather than on the gallery record on purpose: an import rewrites the gallery (new photographs, new order), and curation stored there would travel with it. Every key is validated against the photographs the site actually has before anything is written — a card must show its own country, a strip only its own gallery, at most four, no repeats. And at render time a pin naming a photograph that no longer exists is ignored rather than drawn, so a later deletion degrades to the automatic choice instead of a hole.
The uploader stamps its own build in the bottom-right corner — v436 ·
03e509a · 2026-08-12: a build number that rises with every deploy, the commit
that produced it, and the day it shipped. All three are derived at deploy time, never typed,
so the stamp cannot claim to be a version it is not. Quote the v-number when
reporting anything odd about the page; the commit is there for looking the change up.
If the number has not moved after a change, the Worker deploy did not run — check the
repo's Actions tab.
The import titles an album from the name input, and falls back to its year when
none is given. The upload page always sends one, so this only ever bit the Shortcut, which
sends a path and nothing else — a new album arrived on the site titled
2026 rather than Halong Bay. The Worker now derives the title from the path's
last segment for path-dispatched imports (halong-bay → Halong
Bay), leaving a segment that is itself a year alone so a year-only album still takes
the year. An explicit name always wins, and any title can be changed afterwards from
Gallery cleanup.
When a drop contains folders the page asks what they are: separate galleries
(one per folder, titled from the folder, one import each — they run in parallel) or
places inside one gallery (folder names become place tags). Ignore subfolder
names skips the question and files everything flat. Dragging one wrapper folder strips
just the wrapper, as always. Within an upload, top-level photos go first and each folder
follows in turn; flattened name clashes get a -1 suffix. Re-importing never
reshuffles an album you have already arranged: existing photos hold their positions and
only genuinely new ones append.
Deleting a parent album (say india/1993 after splitting it into
per-city galleries beneath it) never touches the child albums — the purge skips any
sub-folder that has a gallery record of its own, exactly as a relocate does.
You can start one import while another is still running — each gallery gets its own
lane, so they process side by side and land independently. Earlier the whole pipeline
shared one lane, and because GitHub only ever holds one run waiting per lane, a
third import in quick succession cancelled the one queued behind it with
Canceling since a higher priority waiting request for r2-import exists.
That batch stayed in the bucket, unimported, with nothing to show it had been dropped.
How fast can you go? Your upload bandwidth is the only real ceiling. Different albums process side by side, so dispatching a new album every half-minute just stacks lanes. Clicking Start import twice on the same album is harmless — every run re-lists the album’s entire storage prefix when it starts, so whichever run goes last covers everything uploaded by then. One rule of thumb: if you add more photos to an album while its import is still running, click Start import once more afterwards — that sweep picks up the stragglers the in-flight run may have missed. When several imports finish in the same minute they race to record their results; the pipeline retries each one up to eight times with growing pauses, so pile-ups sort themselves out. If a run ever fails at the final "commit" step anyway, the photographs are already processed and safe — re-run that workflow and it redoes only the bookkeeping.
Clicking Start import again for an album that already has a run waiting is harmless: the page detects it and says "already queued — it will include these photos" rather than dispatching a second. (An import that is already running is different — it read storage before your new files arrived, so that case does dispatch a fresh run.)
If you ever see that message on an old run, nothing was lost — the photographs are still in the bucket. Re-run the import for that gallery (Actions → R2 gallery import → Run workflow, same country and gallery path) and it picks up exactly where it would have.
| What | Where it's stored | Where to change it |
|---|---|---|
R2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_ACCOUNT_ID | GitHub Actions secret | Repo → Settings → Secrets and variables → Actions — Cloudflare side: Cloudflare dashboard → R2 → Manage API Tokens to roll the token |
RESEND_API_KEY | GitHub Actions secret | Repo → Settings → Secrets and variables → Actions — Resend side: resend.com → API Keys to roll the key |
TURNSTILE_SECRETRESEND_API_KEY (contact form's own copy) | Cloudflare Worker secrets on traveller-contact | traveller-contact → Settings → Variables. The Turnstile pair is made at Cloudflare → Turnstile (the site key is public and goes in the site build as PUBLIC_TURNSTILE_KEY — in two builds: the Pages environment variables for production, and .github/workflows/deploy.yml for staging, which is a file in the repo because the key is public by design; the secret key goes here). The widget also has to list every hostname it may render on (Turnstile → the widget → Hostname Management): on an unlisted name it draws nothing, which reads as a broken key rather than a missing hostname. Give the form its own Resend key, not the pipeline's, so either can be rolled alone. With either missing the form refuses to send and says only that it is not set up — it never names which key is absent |
Contact Worker code (cloudflare/contact-worker.js)Email Worker code ( cloudflare/email-worker.js) | Live in the repo; deployed as the Cloudflare Workers traveller-contact and traveller-email | Deploy themselves alongside the upload Worker on every push that changes them — same Deploy upload Worker workflow, same token. As with the uploader, the repo file is the truth and dashboard edits are overwritten by the next deploy. The two inboxes theboys@ reaches are a list inside the email Worker (const INBOXES), not a setting in Email Routing — the routing rule only names the Worker. Read them in the traveller-email code view; change them by editing the repo file. Both must also be Verified under Email Routing → Destination addresses, or a forward to the unverified one is refused and only one person gets the mail. |
| Resend SMTP key (Gmail send-as) | Typed into Gmail → Settings → Accounts → Send mail as (set up at cutover, step 16 of the launch runbook) | Roll it at resend.com → API Keys, then paste the new key into each Gmail send-as entry. Deliberately a separate key from the pipeline's, so rolling one never breaks the other |
Upload Worker code (cloudflare/upload-worker.js) | Lives in the repo; deployed as a Cloudflare Worker named traveller-upload | Deploys itself: every push that changes the file runs the Deploy upload Worker workflow (wrangler, needs the CLOUDFLARE_API_TOKEN secret below). The repo file is the source of truth — edits made in the dashboard editor get overwritten by the next auto-deploy. Manual fallback if the token is ever broken: copy the file from GitHub → paste into Edit code → Deploy. |
CLOUDFLARE_API_TOKEN (powers the Worker auto-deploy) | GitHub Actions secret | Create at Cloudflare → My Profile → API Tokens → Create Token → Custom token: two permission rows — Account → Workers Scripts → Edit scoped to this account, and Zone → Workers Routes → Edit scoped to traveller.org. The second is what lets the deploy create the www.traveller.org/api/* route; without it the deploy ships the code and then fails red on that step, which is exactly what happened on 2026-08-20. From 2026-09-14 a third row is needed: Zone → DNS → Edit on traveller.org, for the upload.traveller.org custom domain — wrangler writes that hostname’s DNS record itself. Short of it the deploy fails the same way as before, naming the zone, which reads like a broken config rather than a token one row short. Whether the token already carries it is answered by the deploy, not by memory: a green Deploy upload Worker run after that date is the proof. An existing token is edited in place at the same page (⋯ → Edit) — there is no need to make a new one or to touch the GitHub secret. Add at Repo → Settings → Secrets and variables → Actions. Worker secrets (UPLOAD_KEY, GITHUB_TOKEN) are untouched by deploys. |
ANTHROPIC_API_KEY (drafts banknote write-ups) and the ANTHROPIC_MODEL repository variable naming the Claude model to use | GitHub Actions secret + GitHub Actions variable | Key: create at console.anthropic.com → API Keys, add at Repo → Settings → Secrets and variables → Actions. Model: same page, Variables tab — a model id from Anthropic's model list; upgrading models is editing this variable, never a code change. Both optional: without them scan imports still run and the log says what to configure. |
Worker's own secret — UPLOAD_KEY (the upload/zips-page passphrase) | Cloudflare Worker secret (not in GitHub or the repo) | Cloudflare → Workers & Pages → traveller-upload → Settings → Variables and Secrets. One value, shared by everyone
who uploads — the Worker checks a single key, so a second person's phone carries the same
secret as yours and cannot be cut off on its own. Fine for a couple of trusted people; ask in
a session for per-person keys before it goes wider. Changing it there is the whole
rotation — the upload page asks again on next use, and the iPhone Shortcut needs its one
Text action retyped. Rotate it if it has been seen anywhere it should not be:
pasted into a chat, caught in a screenshot, or typed into the ?key= field of the
Shortcut's upload address — that field is a storage path, not a credential. The Worker
refuses that last one outright rather than filing photographs under a public path containing
the passphrase, but a refused upload is not an un-exposed secret, and rotating is a
thirty-second job |
Worker's own secret — GITHUB_TOKEN (powers the "Start import" and Publish buttons) | Cloudflare Worker secret (not in GitHub or the repo) | Create at github.com → Settings → Developer settings → Fine-grained tokens — scope it to only the traveller.org repo, permissions "Actions: Read and write" and "Contents: Read-only" (the Publish page's waiting-list reads commits; a token with only Actions makes it fail with "github error 403" — edit the token in place and add Contents, the secret value does not change), then add it the same way as UPLOAD_KEY above. Optional: without it the buttons show a friendly message and fall back to "tell Claude" instead of failing. |
CENTRAL_DOCS_GIT_TOKEN (lets the nightly sync read each app’s docs/guides folder for the shelf at /guides) | GitHub Actions secret | Go straight to github.com/settings/personal-access-tokens/new — that one link is the whole of the finding-it problem. (Clicking there by hand: your avatar, top right → Settings → then scroll to the very bottom of the left-hand list, where Developer settings is the last item → Personal access tokens → Fine-grained tokens. It is under your account settings, not the repository’s — a repository’s Settings tab has no Developer settings, which is where this is usually lost.) Fill in: any name, an expiry, Resource owner: brarob100, Only select repositories → the app repositories, then Repository permissions → Contents → Read-only and nothing else. Copy the token when it is shown — it is never shown again — and add it at Repo → Settings → Secrets and variables → Actions. Optional: without it the Sync app guides run ends green having done nothing and says so, and this site’s own guide is on the shelf regardless. Fine-grained tokens expire (a year at most) — when the run starts reporting 401, this is the one to re-create. Created by you on 2026-09-05. |
CENTRAL_DOCS_DISPATCH (lets an app’s own repository ping this one so a pushed guide reaches the shelf within a minute instead of overnight) | GitHub Actions secret in each app repository that carries the three-line dispatch step (updating page, “Adding an app’s guide”) | Entirely optional — without it the nightly sync still brings every guide across by morning. One token serves all the apps: create it at github.com/settings/personal-access-tokens/new (same account-settings path as the row above) with any name, an expiry, Resource owner: brarob100, Only select repositories → traveller.org alone, and Repository permissions → Contents → Read and write (write is what the dispatch API demands; the token still cannot touch the app repositories, only ring this one’s bell). Copy it, then in each app repository that has the dispatch step: Settings → Secrets and variables → Actions → New repository secret, name CENTRAL_DOCS_DISPATCH, paste the same value. When it expires the app’s dispatch step fails with 401 while the shelf keeps filling overnight — the tell that it is this token, not the sync’s. |
RESEND_API_KEY on the traveller-dmarc Worker (lets the DMARC reader email you when a report needs acting on) | Cloudflare Worker secret (a third copy of a Resend key, alongside the pipeline's and the contact Worker's) | Workers → traveller-dmarc → Settings → Variables → add RESEND_API_KEY (encrypted). Use a key made at resend.com → API Keys — a separate one, so revoking it cannot silence the pipeline or the contact form. Without it the Worker still reads every report and simply cannot tell you, which looks exactly like a clean week — so set it before trusting the silence. |
FRIEND_KEY (the friend password — opens the unlisted albums on the live site) | Cloudflare Pages project setting (not in GitHub, the repo, or any page) | Cloudflare → Workers & Pages → the site’s Pages project → Settings → Variables and secrets → FRIEND_KEY, environment Production; the value takes effect on the next deploy (the next Publish). Changing it signs every friend out at once — their year-long cookies are derived from it. Unset, the door is closed, not open: unlisted pages answer “switched off”. This is a third key, deliberately separate from UPLOAD_KEY (which starts machinery) and the Cloudflare Access allow-list (which is you): friends get pages and nothing else. |
Cloudflare links above go to the account picker if more than one account is signed in — pick the traveller.org account and they'll land on the right page next time.
Photo removal (the photo view's Remove from gallery button, or the Gallery cleanup page) is a two-step machine:
GET /photos to list a gallery's bucket contents and
POST /remove-photos to delete — both passphrase-gated like everything else).GITHUB_TOKEN lapsed) the files are still gone from storage and the
page will say to tell Claude "sync gallery: country/slug" — the site just shows broken
tiles for those photos until the sync runs.The upload page's Film mode takes one clip at a time with its title, description
and location typed right there. The video lands in films-in/<country>/<year>/
with a small .json sidecar carrying the text (and, since 1 September 2026,
the Unlisted choice, so a film meant for friends is never public for even the window
between conversion and curation); Convert & publish →
dispatches the import workflow in films mode, which runs ffmpeg on the Actions
runner: H.264/AAC MP4 (long edge capped at 1280 px, +faststart so playback
begins before the download finishes), a poster frame, and a 460 px thumbnail. Outputs
live under films/<country>/<year>/; the record is written to
src/data/films.json and the site rebuilds itself.
films-in/ untouched, the run reports which file failed, and nothing is
deleted — re-dispatch after fixing (usually a truncated upload; just upload it again).The Gallery cleanup page's Album details card dispatches edit-album,
the lightest mode in the pipeline: no bucket work, no email — it rewrites the album's
name and/or year in galleries.json and the site
rebuilds. Works on legacy and high-res albums alike. The album's
path never changes: it is the record's identity in the data, the folder every photo
lives under in the bucket, and the public URL — renaming it would mean copying every
object and breaking every inbound link. Year must be four digits; it feeds the album's
sort position and the country's "travels" span, which is derived from gallery years
rather than stored anywhere.
It edits a record, and only a record. The Gallery cleanup picker also lists album
folders found in the bucket, including ones that were uploaded and never imported and
so have no record at all. Those are marked · not imported and the card offers
them an Import this album button in place of the fields — the mode's inputs
would have nothing to write on. The distinction comes from thumbnails, which only the import
makes: the Worker probes thumbs/<country>/<slug>/ for a single object
per unknown folder. Before that, the Unlisted switch was offered for such a folder and five
edit-album runs died in one minute on “No album at brazil/2004”
(owner, 20 August 2026); the mode's own message now names the import as the fix.
What may be pinned is the record, not the folder. edit-covers
validates every key against galleries.json, and the admin picker used to list
the bucket, so an album whose folder holds more than its record publishes offered
photographs that were then refused one at a time — and the refusal for a strip said
the photograph was not from its own gallery, which was the one thing it plainly was
(owner, 2026-08-20). The picker now reads gallery-order.json, which is the
record; the message names the album, its published count and the sync that
would fix it; and never-imported folders are not offered at all. Keys are matched through
the rename-chain stem here too, so a pin made in the minutes between a rotation and
its commit saves under the record's own spelling instead of failing.
reorder saves a gallery's photo sequence: the admin page sends the full
ordered list and the importer proves it is a complete permutation before writing — a
partial list would silently drop photos, so it is rejected with a "reload and retry"
instead. That proof matches on the rename-chain stem, not the exact filename: a
rotation renames a photograph to -r1 in storage the moment it runs, while
galleries.json carries the old name until that run's commit lands, so the page
and the record can hold different generations of the same names for a minute or two. An
afternoon's arranging of turkey/2022 was thrown away by the stricter test
reporting "76 missing, 76 unknown" about the same 76 photographs (owner, 20 August 2026).
The sequence comes from the submitted list; the filenames written are always the record's
own. edit-films does the same for a country's films and also records
where each film features (country page / home page) and, since 1 September 2026, whether a
film is unlisted — every flag is stated on every save, so unticking works.
Both are record-only edits.
The order is durable: sync (after removals) and re-imports preserve the
existing sequence for surviving photos and append new ones at the end, and the pipeline's
record-level merge carries a pure reorder through even when another gallery's import lands
at the same moment.
A banknote is a record too (2026-09-13). currency.json is shaped
{country: {currency, notes}}, and until this date the merge's
smallest unit inside it was the whole country: a run that re-cropped one French note
wrote its entire France list over whatever the branch had. A delete-note run
removed a note, a recrop that had checked out two minutes earlier put it
straight back — pointing at scans the delete had already removed from the bucket, so
the page carried a banknote with no photograph. Nothing failed and nothing was logged; the
record simply returned. The merge now keys the notes list by id,
so a note a run never looked at keeps whatever the branch says about it, deletion included,
while the run's own re-crop still lands.
The Journal editor posts to the pipeline's
edit-journal-entry mode — one entry per save, which keeps the payload inside
the dispatch limit and makes the record-level merge's unit match the edit's unit. The body
is reduced to the journal's own vocabulary by the sanitizer (anything else flattens to its
text), a photograph key no gallery holds is refused by name, and a body that sanitizes down
to nothing is refused rather than blanking a letter.
A caption nobody wrote is not stored (2026-09-13). The editor used to insert
“Caption — type it here” as real text when a photograph was placed, so a
photograph left uncaptioned published those words under it. The prompt is now drawn by CSS on
an empty caption, the sanitizer drops a <figcaption> that is empty or still
holds the prompt, and src/lib/journal-html.js applies the identical rule at build
time — because 161 letters saved before today are not going to be re-saved. The two
implementations have to agree: an entry stored clean and rendered dirty, or the reverse, is
the failure the pair guards against, which is why the pipeline's half is its own named
function rather than a line inside the sanitizer.
Two things on the record are derived, never typed: the word count the site quotes, and the excerpt every index card shows — both recomputed from the surviving prose on each save, so rewriting an opening moves the card with it.
Creating uses the same mode with create: true. The page computes the
slug from the title the way a new gallery's name becomes its path, and the run refuses a
slug the journal already holds rather than merging into it — two letters may share a title,
and quietly overwriting one is not a save. The record is appended with an id past the
highest in the file, and the country field decides which country page carries
the letter; a country the site does not have is refused by name.
Resolved at build time in four falls (journalImage() in
src/lib/data.js), so it re-answers itself on every deploy. The owner's rule,
13 September 2026: “the image shown should always come from the journal post
associated to it. Only if there is no image can an alternate image from the same country
and the same year of the journal entry be auto selected.”
src/data/journal-photos.json keyed by entry slug — if that photograph still
exists. A pick whose photo was deleted, or which now sits in an album that has been made
unlisted, falls through rather than rendering a gap or leaking a hidden album's picture.public/legacy-media/, served from there). Six
letters have one, and until this date they showed a stranger’s photograph from the
same country instead.The entry's own hero field is deliberately ignored. Of 162 entries it holds 71
dead .jpg.html links, and the 16 real images among them belong to entries that
already have a pick. The Journal photos card labels every
row with which fall produced its image, so the reason a picture is there is never a guess.
Pinned by tests/journal-layout.test.mjs — including the same-year half,
which has no counter-example in today's data and is therefore unit-tested against the rule
itself rather than against the built pages.
Every page that shows an entry as a card uses this one resolution — the journal
index and the “From the journal” panels on country pages alike. Until
2026-08-19 the country pages rendered from hero instead, so a pick made on
Journal photos reached /journal/ and nowhere else, and the country
panels showed a GIF from the old server or the locator map. journal-photo.test.mjs
now checks every card on every built page against the picks.
The control is the Journal photos page. Saving a pick runs the pipeline's edit-journal-photos mode, a record-only
edit that touches no storage. It refuses a photograph no gallery contains and an entry slug
the journal does not have, both of which mean the page was working from a stale list.
journal-photos.json is merged key-by-key at commit time like
home.json, so concurrent runs do not clobber one another.
The Notes tab uploads note scans to currency-in/ and dispatches the
pipeline's currency mode. The filename carries the entire mapping
(<country>-<id>-f/-b, exactly the note page's own URL name), and
every name is validated before anything is processed — one typo fails the whole run
naming the file and the nearest country slugs, rather than publishing nineteen notes and
silently skipping the twentieth. An id the record set does not have creates the note
(denomination = first token, variant = the rest).
A country new to the whole site is created in full, or the run refuses. A note
whose country the site has never held needs a region and a display name — neither of which
a filename can supply — and the upload page collects both before it will upload anything,
passing them to the run as new_country
(slug|region|Display Name, semicolon-separated for several). The run then
writes a real country record (page, region ink, locator map) alongside the currency one, so
the country is counted by every “N countries” on the site. Dispatched by hand
without them, the run stops before it processes anything and names the countries it
is missing — the scans stay in currency-in/. Until 2026-08-19 it quietly wrote
a currency-only record instead, and such a country would have held banknotes while the home
page, /countries/ and the llms files all counted without it.
Each accepted scan — PSD composite (save with Photoshop's "Maximize Compatibility", the default), JPEG, PNG, TIFF or iPhone HEIC — becomes three JPEGs in the bucket:
| Tier | Size | Key | Serves |
|---|---|---|---|
| Large | 3000 px long edge | currency/<country>/<name>.jpg | the note-page lightbox |
| Stage | 920 px wide | currency/stage/… | the note page (sharp at 460 CSS px on 2× displays) |
| Thumb | 320 px wide | currency/thumbs/… | grids and country bands |
A scan under 1200 px wide publishes, but gets no lightbox. Below that the
"large" is no bigger than the display copy already on screen, so offering to enlarge it
would promise detail that is not there. The run warns and names every such file with its
measured size — a banknote scanned at 300 dpi lands around 1800 px wide, so a
few hundred pixels means the source was a web-sized copy rather than the scan. The width
is recorded on the record (frontHiW/backHiW) so the decision is
measured, never guessed at render time.
The note record gains frontHi/backHi pointing at the large.
Nothing is ever upscaled — a source smaller than a tier ships at its own size.
A 100 px scan is retired the moment its replacement lands — and not before, and only on that side. They were the original site's grid tiles, and once the library had been re-photographed the whole set was taken out of the repo. That was wrong for the notes the big upload never reached: their 100 px scan was the only copy of that photograph, so 63 files went back (2026-08-19). What remains retired is the 32 files whose notes now have a better picture. A note whose front has been re-shot and whose back has not keeps its old back until that side is done too.
A crop can be redone without the original. The automatic crop misses occasionally
— most often on a polymer note, whose clear window lets the dark ground through and
used to read as a second note in the frame. The recrop mode re-crops the
3000 px large already in the bucket, which is the same photograph minus the resize, so
the deleted intake file is not needed. It publishes a new version if it finds an edge and
leaves everything alone if it does not. The control is Crop the scan again on
Banknote pages. Since 25 August 2026 the mode has a second
reach: when the measuring cropper finds nothing to do and the Claude credentials are
configured, it asks the model where the note sits and which way up it is, and cuts and
turns by that answer — which is how a phone frame that published raw (the euro 10
back went out sideways on black) is healed after the fact.
Two behaviours worth knowing: re-processing chains a version (-v2,
-v3…) because every object is served immutable for a year — a better scan
takes a new name, never repaints an old URL — and the old version's three tiers are deleted
after the new ones are written. The intake file is deleted after a successful publish —
keep your PSD originals locally.
Rebuilt 2026-08-19, when the whole collection went over to being re-shot on a dark ground: the crop now has to hold for a couple of hundred frames with nobody checking each one. It works on a downscale of the frame (an outline is a large-scale feature), and:
And it refuses what it cannot measure — two objects in frame, a box that is not solidly note — which is right, but refusing must not mean publishing the raw frame, and a 90° turn is invisible to it entirely (a rectangle has no reading direction). Since 25 August 2026 those frames are covered by the model instead: the identification answer carries the note’s box and rotation, the pipeline cuts and turns by them at the rename, and this cropper runs inside the result as the fine pass. The identification section has the details.
What the upload endpoint does to an address before storing anything (2026-08-19,
every rule below written after a real failure). The address is a storage path —
country/year/album/filename — and its first part is whatever a person
typed into a prompt on a phone, so it is tidied where the input is merely untidy and
refused where it is actually wrong.
Tidied, because none of these is a mistake worth losing an upload over: a slash typed on the end of the destination, or doubled where that meets the one the Shortcut adds; a slash at the front; spaces around it from dictation; and a capitalised first letter, which iOS adds to every prompt and there is no way to refuse. The destination is folded to lower case because the import looks for the lower-case spelling — the filename keeps its capitals exactly, since that is the photograph's identity on the site.
Refused, because storing it would do damage quietly: an address that ends with no
filename — a variable in the caller's address resolved to nothing, and every photograph
in the batch would be filed under the album's own name, each overwriting the last. And an
address containing the passphrase in any capitalisation: the parameter is called
key and reads like a credential, and photographs are served publicly, so a
passphrase typed there would be legible in every picture's address.
And the Shortcut is told what happened, in words. When the phone asks for an import, the endpoint counts what is actually in storage at that destination first, and answers with a sentence the notification shows: "12 file(s) at spain/2026/vallarta — import started." or "Nothing at spain/2026/vallarta — no photograph arrived, so no import was started." Nothing is dispatched when nothing arrived: a run certain to fail costs build minutes and its email says less than that sentence, which reaches the phone where the email does not. The upload page's own dispatch is deliberately untouched — it watched each of its uploads succeed and does not need storage asked a second time.
/albums.txt is the Shortcut's album list (2026-08-20): the same
passphrase-gated index /galleries serves the admin pages, as plain text —
one album path per line, sorted, with + New album… as the first
line so the pick-or-type fork lives in the data rather than in extra Shortcut actions. Only
base:"r2" albums are offered; a legacy record has no base field at
all, which is why the filter checks for the flag rather than defaulting its absence —
the default let every legacy album through on the first try.
The Worker answers at two addresses (both live since 2026-08-20). Its own
traveller-upload.brarob.workers.dev, and www.traveller.org/api/
— a Workers route declared in wrangler.toml and created by the same deploy
that ships the code, so there is no DNS record to write and no dashboard step. The first
deploy was refused on the route because the API token carried Account · Workers
Scripts · Edit alone; with Zone · Workers Routes · Edit added
the next push made it. The second address exists because the admin pages live on www.traveller.org: reached that
way the calls are same-origin, which removes the CORS preflight and, with it, every
way a DNS filter, a corporate network or a content blocker can refuse a
workers.dev hostname. After the Access lockdown every interactive admin page
went blank on “Failed to fetch” — a failure with no HTTP status at
all, from a Worker that answered perfectly from outside the browser (2026-08-20).
The admin login works the address out for itself. On the live domain it offers
/api, asks it one question before trusting it, and uses the
workers.dev address instead when what answers is the site's not-found page
rather than the Worker — whether the route exists is a Cloudflare-side fact no build
can see, so the page checks rather than assumes. A remembered address still wins: it
is a choice somebody made on that device. Press Sign out to have it worked out
afresh.
“Failed to fetch” is not a fact about the Worker. It is what a browser
says when the request never left it: no HTTP status, so nothing server-side was asked. The
login now translates it, and differently depending on where it is running, because the way
out differs: on the live domain it points at extensions, and on staging it points at the live
domain. Staging can only reach the Worker cross-site — github.io
has no route of its own to offer — so a browser set to refuse cross-site requests
refuses every admin call it makes, while answering the same Worker in a tab (owner,
2026-08-20). The tool pages had been blaming the Worker for this; one of them replaced the
browser's words entirely with advice about pasting Worker code by hand, which is how a
browser-side refusal came to read as a stale deployment.
And nothing waits forever. Every call the login makes carries a fifteen-second
deadline. A hostname a network is black-holing does not refuse — it hangs, and these
pages wait on the Worker before they draw anything, so staging showed empty panels for
minutes with no error anywhere (owner, 2026-08-20). The login is now on screen while
it connects, and gives up out loud. Staging's build points at
www.traveller.org/api for the same reason: same Worker, by a name that
answers.
workers_dev = true is written out in wrangler.toml rather than
left to the default: it defaults to true only while no route is configured, so adding the
route would otherwise have taken the workers.dev address off the air —
the address the iPhone Shortcut carries, and the way back in when the domain is misbehaving.
And Cloudflare Access must never cover /api/*: the Worker's door is its
passphrase, and Access in front of these calls breaks the admin pages in exactly the way
this route was added to end.delete-note removes one banknote and its pictures. The scans live at
three keys per side — the 3000px large, and a stage and thumb tier under
currency/stage/ and currency/thumbs/ — all derived from the
one address on the record, so a note that was only half re-shot loses exactly the tiers it
has and its 100px repo thumbnail for the side that was not. Objects go before the
record: a record removed first leaves files nothing points at, findable only by listing
the bucket, while files removed first leave a note whose page shows the “no photograph
yet” tile — a state the site already knows how to draw, and one a re-run
finishes. A country left with no notes, no photographs and no journal entries is retired by
the same retire_empty_country that deleting an album uses.
currency one. A file outside the sparse checkout cannot be deleted and
the script's “is it there?” guard turns that into a silent success. This was a
list of modes until delete-note needed the same files — the identical
shape that let a locator map outlive its country hours earlier, in the same workflow.Files nobody imported, and the prune mode that removes them. A batch
uploaded to a folder that is never imported keeps sitting in storage: no gallery record, and
no thumbnails — those are made by the import. The admin pickers show such a
folder deliberately, marked not imported, because photographs waiting in the bucket
are exactly what the owner wants to see; what they must not show is a grid of broken frames,
so a missing thumbnail now falls back to the full-size object and then to a labelled tile.
Two arrived on 2026-08-20: 260 originals directly under cuba/2009/,
duplicates of photographs already published a level down in 2009/reynald and
2009/trinidad; and an iPhone batch under Mexico/2026/vallarta,
capitalised before /put began folding paths to lower case. Bucket keys are
case-sensitive, so Mexico/ is a different country from mexico/
— it showed up as a second Mexico in every droplist.
prune deletes only what sits LOOSE directly under a
folder. A key with another slash in it belongs to an album nested beneath — under
cuba/2009/ that is four published albums — and is never touched, only
named in the log. delete-album cannot do this job: it needs a gallery record and
it purges a whole prefix. The same rule now governs what the admin pages LIST for such a
folder: its direct children only, so Gallery cleanup can no longer offer another album's
photographs for deletion.The site is static, so there is no session and no server to decide who is asking. The country page therefore ships no trace of an unlisted album at all — not a hidden element, not a CSS rule, not a commented-out block. Rendering the cards and hiding them with a stylesheet would be the same disclosure with one more step in front of it, and it would pass every “is it listed?” check ever written against the page.
What ships is an empty container and a script. If the browser holds the upload passphrase,
the script asks the Worker for the album list — the same passphrase-gated
/galleries the admin tools use, which re-checks the key before it answers
— and builds the cards from what comes back. Visibility on the page is convenience;
the passphrase on the call is the control, exactly as with Rotate and Remove on photo pages.
tests/hidden-albums.test.mjs builds the site twice, with an album
flagged and unflagged, and asserts of the flagged build that the country page gained the
container and not the album — plus that nothing in the entire build carries the
album’s address or its thumbnail prefix except its own page. That last check found a
real leak on the day it was written: the manual’s own worked example had used a real
unlisted album as its sample line, and /admin/ is behind Cloudflare Access in
production but not on the staging host.
prune-filesprune’s one rule is also its one limit: it takes everything
loose under a folder, so it refuses a folder any gallery claims. Under
brazil/2005/morro-de-sao-paulo the album’s own 75 photographs sit loose,
so “everything loose” would be the album. A 76th object, 000000.JPG,
sat beside them in no record and on no page, and nothing could remove exactly it
(owner, 21 Aug 2026).
prune-files names files instead of sweeping folders, so it can reach into a
published album’s folder. Naming is sharper than sweeping, so it carries two
guardrails rather than one:
delete-album; one photograph is Remove on its own page."loose": true sweep spares those and names them,
unless "force": true is set. brazil/2005 held 165 loose
files — most byte-identical to photographs published a level down, and the rest not.
Nothing about a sweep can tell those apart by looking.An explicit "files" list is different: it deletes what it names.
Naming a file is already a decision about that file, and making the deliberate case ask for
force as well would teach the habit of always sending it — which is the
sweep’s guardrail gone. The log and the email still say when a named file was the only
copy, so nothing is given up quietly.
"report": true does the whole classification and deletes
nothing, which is how a folder gets decided about before anything is destroyed.
The checksum is R2’s ETag, which for an object stored in one part is
the MD5 of its bytes — so nothing is downloaded to compare it. A multipart
upload’s ETag ends in -<parts> and is not a checksum of anything, so
those objects are read and hashed instead. Treating one as a hash would make two unrelated
photographs look identical, and this mode deletes on exactly that comparison.
The country is folded at both ends, so this cannot recur from a phone. /put folds the folder of an upload to lower case (the filename keeps its own
capitals — it is the photograph's identity), and /dispatch folds the path
the Shortcut asks to import. Each has a test; the residue in the bucket predates them.
The Shortcut's "N file(s)" now counts what arrived in that batch, not what the folder holds, and refuses to start an import when nothing did. R2 gives each object an upload time, so the Worker counts the ones under thirty minutes old — generous on purpose, since a false "nothing arrived" blocks a good import and a long batch only needs its last upload inside the window. An object with no timestamp counts as recent, because unknown must not read as absent. The old total answered "did my photographs land?" only for a new album, where the total is the batch; for an existing one it reported a batch that never landed as a success (owner, 2026-08-21).
A country the site has never held is now refused at /dispatch, for
Shortcut imports only (kind: "import-path"). The import can create a country,
but only when handed a region and a display name, and the Shortcut has no way to ask for
either — so the run was certain to fail, minutes later and by email, while the phone
had already said "import started" (owner, 2026-08-21, sharing to
Iran/2026/sf). The refusal fires only when the roster actually loaded:
countrySlugs returns an empty list if the site index is unreachable, and
treating that as "no country exists" would refuse every upload the moment the index
blinked. The upload page's own dispatch is untouched — it collects
new_country, so for it an unknown country is a legitimate request.
Lower case alone was not enough. On 2026-08-20 a Shortcut batch landed under
united-states/2026/sonoma-house — already lower case, so nothing folded
— and the import refused it: “No country 'united-states' on the site”.
The site's slugs carry no separators (unitedstates, hongkong,
southafrica), because that is how the URLs were minted. Both ends now
snap the first segment onto the roster: strip everything but letters and digits from
what was typed and from each known country slug, and if they match exactly, use the site's
spelling. United States, united-states and Hong Kong
all arrive where they belong. A country the site has never held matches nothing and passes
through untouched, so a genuinely new country still reaches the import's
new_country guard rather than being bent onto the nearest thing. When
/dispatch snaps a country whose photographs are already sitting under the typed
spelling, it sets from_slug to that old prefix, and the import relocates the
batch on its way in — the stranded batch fetches itself back without anyone needing to
know that from_slug exists (tests/country-snap.test.mjs).
Currencies that belong to no country. A banknote filename names its country, and
euro-50-f.jpg names something that is not one. The currency import creates a
full countries.json record for any slug the site has never held — which is
right for the Cayman Islands and wrong for the euro, and on 2026-08-20 the site duly grew a
country called Euro, with a page, a card on /countries/ and a share of every
“N countries” it quotes, while the ten countries that actually use the euro showed
none of its notes. SHARED_CURRENCIES in src/lib/data.js is the
authority: it names the shared currency, its region, and the countries that use it with the
year each adopted it. Those members are what puts the euro band on a country page —
above that country's own notes, current money over what it replaced. The import script holds
the same slugs (SHARED_CURRENCIES in r2_import.py) so that a scan
for one is never asked for a region, never given a country record, and never swept up by the
empty-country retirement.
And every note headline leads with its currency's symbol — a hand-typed map,
since nothing in a scan or a filename carries one. It was two-thirds complete for months and
nothing said so; now the build log warns for any currency without one and
tests/currency-symbols.test.mjs fails against the built pages, which is the
claim that matters: not that the map has an entry, but that the headline shows it.
Unlisted albums, and where the list of them is allowed to exist. A gallery record
carrying hidden: true is dropped from one list in src/lib/data.js
— and because every listing, count and feed on the site reads that one list, the album
disappears from all of them at once: home page, country page, /galleries/,
stats, llms.txt, llms-full.txt. Its page is still
built, with noindex on it, and the sitemap filter in
astro.config.mjs leaves it out.
Nothing about it goes in robots.txt. That file is world-readable, so a
Disallow line naming an album would publish the address the album is hiding
behind. Same reason the public gallery-index.json omits them: it is a static file
anyone can fetch. The upload page still needs to see them, so the passphrase-gated /galleries endpoint on the Worker is where the two halves meet — it reads
the public index, then asks the bucket what else is there, and returns the difference marked
unlisted. That set also catches photographs uploaded but never imported, which is worth
seeing too.
/galleries on connect, every page took that long
to open, at both of the Worker's addresses, getting slower with each import. It now
lists prefixes: countries, then their album directories, in parallel — a known
album costs one directory entry however many photographs it holds, and only the unknown
handful are ever counted object by object. The whole bucket answers in well under a second,
and the cost grows with the number of albums, not photographs. This was the true cause of
the “admin pages take two minutes” week — not Cloudflare Access, which
never touched these calls, and not the Worker's address, which only decided where the
slow answer came from.tests/gallery-list-paging.test.mjs lists the same bucket in pages of 1000
down to 1 and demands the same albums every time.A re-import keeps the flag. The import rebuilds a gallery record from scratch every run, so the flag is carried over deliberately — without that, uploading a second batch into an unlisted album would publish it, silently and in the worst direction.
And it refuses rather than guesses. A frame is published exactly as shot, with the
reason in the log, when: the subject is under 10% of the frame; the frame is entirely dark;
the bright region is not a solid rectangle (a hand holding the note); two subjects of
similar size share the frame — shoot one side per photograph; or the image cannot
be analysed at all. A flatbed scan already cropped tight has no dark ground and passes
through untouched, exactly as before. Every decision, crop or refusal, prints per file in
the run log with its measurements. Held by tests/note-crop.test.py, which
builds frames whose true note box is known and checks the crop against the truth.
Once the tiers are safely published, the same run sends each newly scanned note's front
and back to Claude (the Anthropic API — ANTHROPIC_API_KEY secret and
ANTHROPIC_MODEL variable, credentials table below) to draft the page’s
three sections: The artwork, In circulation, Details. The prompt
works in two passes and in this order: read what is printed on the note (issuer,
dates, signatures, what the portrait and vignettes physically show), then identify from
knowledge — and omit anything uncertain rather than guess: no invented watermarks,
printers or dates, and never a Collected row, which the site derives itself. The answer’s
shape is enforced by the API (a response schema), not merely asked for: the very
first hand-written draft came back with an unescaped quote inside a sentence and could not
be read at all, which is a silly way to lose a write-up.
Three guarantees, each tested (tests/note-writeups.test.py,
tests/note-review.test.mjs):
aiWritten; Banknote
write-ups lists everything carrying that stamp for the owner to read, edit or remove.
Saving sends the text as edited, re-validated with the same caps, and clears the
stamp. Holding write-ups behind approval was the original design and it was wrong: every
re-shot note showed derived filler while good writing sat unread in the record.aiWritten stamp became aiCleared, which
keeps them off the review list while the note’s card still reads “written by
Claude 2026-08-19, cleared unread”. Reading one later and saving it makes it the
owner’s copy in the ordinary way, and a re-draft clears the stamp because the words
are new.note-writeups mode)
retries any note left behind.Without the key configured, scan imports run exactly as before and the log says what to set. The model is deliberately a repository variable, not code: upgrading to a newer Claude is a dashboard edit.
The import workflow takes an optional from_slug. Set it and the batch is
relocated inside the bucket first — a server-side copy, so nothing is re-uploaded from
your machine — then imported at the new path. The old gallery record, its place tags and
its stale thumbnails are cleared automatically. Safe to re-run: keys already at the
destination are never moved onto themselves, which matters when the old path is the
parent of the new one (vietnam/2009 → vietnam/2009/halong-bay).
Add files to that same from_slug run and the move becomes partial:
only filenames matching the patterns travel (pipe-separated globs, case-insensitive,
matched against the basename — agra*, or agra*|taj*), and the
source album survives minus what left. That is the repair for an album that swallowed
several batches because every batch was dropped on the same destination: split one stem
out at a time, each into its own path with its own name. RAW and its developed JPEG share
a stem, so a pair never gets separated. The source record loses exactly the photos that
moved, their place tags go with them, and the record is retired only when the last stem
leaves — so the album is never briefly pointing at objects that are no longer there.
The patterns come from verify: run it on the album and any folder holding more than 60 files prints a census of its name stems — the leading non-digit part of each filename — with counts and first/last file. When several batches merge into one album the filenames are usually the only surviving record of which photo came from which batch, and that census turns them into the split plan. A pattern that matches nothing stops the run rather than falling back to moving everything.
Each import's zip (developed 2048 px JPEGs + manifest, foldered by region/country/place)
lives under zips/ in the bucket and is listed on the
zip records page with a Remove button — removing
deletes the object from R2, so download first. The email link and the page link are
the same file.
pipeline@traveller.org, using the
traveller.org domain verified in Resend. To change either address without touching code,
add a ALERT_EMAIL or RESEND_FROM GitHub secret. If an email
doesn't arrive, run the workflow in verify mode — it sends a test email in about
twenty seconds and prints the exact reason on failure, without needing a gallery import.
(The first failure here was error code: 1010 — Cloudflare, which fronts
Resend's API, rejecting the default Python user agent; not a Resend problem at all.) The
zip link is never lost either way: it is printed in the run log and always listed on
the zip records page.The folder traveller-uploads is read directly by Claude's Drive connector — right for small items: the favicon came in this way; one-off journal images too. Photo batches don't go here — use the bucket. Currency scans used to route through Drive; they now go through the upload page's Notes tab (below), which is bucket-backed like everything else.
rclone copies straight from your computer into the same bucket; Claude
supplies the one-time config and the one-line command when you're ready. The same
import workflow processes it bucket-side.
Uploading the first batch for a new country creates that country, and for a few
minutes afterwards the phone could refuse the second batch with
“‘ireland’ is not a country on the site” — while the
album picker on the same phone was happily offering
ireland/2019/galway. Both halves were doing what they were told: the picker reads
the site’s index fresh every time, and the check that refuses an unknown country read the
same file from a fifteen-minute cache. For those few minutes one of them knew about the
new country and the other did not, and the one that did not had the veto.
Fixed: a country the cached list has never heard of is now looked up again, without the cache, before anything is refused. A country that is genuinely not on the site is still refused — on fresh evidence — and a country already on the list costs no extra request, so a fifty-photo batch does not pay for this fifty times.
If you ever see that refusal for a country you know exists, it is worth checking the country page on the test site before anything else: the phone is reporting what the site’s own index says, and the index is only ever a minute or two behind a pipeline run that finished.
Creating an album from the upload page writes a single empty marker file at
<country>/<year>/<album>/.album and nothing else. That one object
is enough to make the folder exist, which is all the phone’s picker needs.
The marker is harmless if it is left behind, and prune-files can
remove one by name if you ever want to. It is not counted, not published and not imported.
The marker also carries the Unlisted choice made when the album was created (24 August 2026). The Shortcut has no way to ask about it and sends no such flag, so the import made from the phone reads the marker instead — which is how an album created unlisted publishes unlisted on its very first batch.
Films have the same shape since 1 September 2026: a film’s
hidden flag is stripped from every public page and feed at the data layer, the
build emits the hidden records into the gated /friends/films.json, and the
films pages weave them back in for a signed-in friend with the ⊘ Unlisted band. A
re-conversion of the same clip preserves the flag (and Country/Home placements) rather than
rebuilding them from nothing — the fourth field in the lineage the re-import suite
guards.
Since 1 September 2026 the marker carries the typed album name the same
way, stored URL-encoded in its metadata so accented letters survive the storage API. The first
import reads it back as the album’s display name — behind an explicit name on the
dispatch and behind a name already on the site’s record, ahead of inventing one from the
address. The uploader’s album list reads it too, so an empty album introduces itself by
its typed name rather than its address tail. The address itself transliterates accents
(í becomes i) instead of dropping the letter. The same
change removed a trap on the other side: a path-dispatched import (phone, Shortcut) used to
invent a name from the address and send it as the explicit name, which outranks
everything — so the batch after a rename renamed the album back. The dispatch sends no
invented name now; the readable-from-the-address fallback lives at the bottom of the
import’s chain, behind the record and the marker.
A held photograph sits in currency-in/auto/ because the reader could not be
sure, timed out, or read the side backwards. The person who took it usually knows exactly
what it is — so the currency run now takes that answer. In the
files input:
{"name": {"1789320813201-59.jpeg": "france-10-richelieu-f.jpg",
"1789320813201-34.jpeg": "france-10-voltaire-b.jpg"}} The identity is then yours and outranks every test; the box and the rotation are still
the reader's, so the note is cut out of the phone frame and stood upright exactly as an
identified one is — the same division recrop mode already uses (identity beside the
point, geometry the whole point). A name that is not
<country>-<id>-f.jpg or -b.jpg is refused before
anything is written, and a name already waiting in the intake is refused rather than
overwritten. The run log prints named <file> -> <name> (you said so)
for each one.
Where to read the held list: the run's own warnings name every file and why
it was held. The photographs are in the bucket at
currency-in/auto/ and can be looked at directly — the bucket is public,
so the address is the base URL plus that path.
When a photograph of a note is identified, the reader also says where the note sits in the frame and which way up it is, and the run cuts and turns it before anything else happens. That answer can be wrong in one specific way: a note lying on its side can read as merely upside-down, and the answer comes back “180°” — which turns the note over and leaves it exactly as vertical as it was. Two of the 41 scans in the batch of 13 September 2026 published standing on end for that reason (France 20 front and France 200 Eiffel front; the owner spotted them on the country page).
So the shape is now checked after the turn rather than trusted from the answer. A banknote is wider than it is tall: if the cut note is still clearly taller than wide (taller than 8:7) after the reported turn, it is turned a further 90° clockwise and the run log says so in words. Only when the reader actually found the note — an answer that reports no box and no rotation located nothing, and turning a whole phone frame on that basis would be a guess about a photograph nobody read.
A genuinely vertical note exists (a few Swiss and commemorative issues) and
would be turned wrongly by this rule. That is why the extra turn is never silent: the line
in the run log is what points at the reason, and recrop mode — which re-cuts a
published note from the 3000px copy in the bucket — is the way back. Held by
tests/currency-auto.test.py, including the 180°-on-a-sideways-note case
that caused it.
The currency intake accepts a second kind of file now. Alongside the hand-named scans
in currency-in/, photographs from the phone land unnamed under
currency-in/auto/, and the same currency run identifies them before the
usual name validation: each one is sent to the Claude API and answered as
country + denomination + side plus the note’s geometry — where the note
sits in the frame and which way up it is — constrained to the intake grammar by
schema. A certain answer cuts the photograph to that box, stands it upright, and
saves the result into currency-in/ under exactly what a hand-named scan would
be called (turkey-50-f.jpg — always a JPEG now, whatever the camera
format was) — and from there every step is the existing one: validation, fine crop,
display tiers, records, write-ups.
auto/, with a
warning per file in the run log saying why. Nothing is guessed onto the site.^0, and was held.HTTP 529 Overloaded and the note was held after a single
attempt. Identification and write-ups now retry a 429 or a 5xx four times, waiting 3, 9 and
27 seconds; each retry is printed in the run log. A 400 or 401 is not retried
— a wrong request or a wrong key gives the same answer four times over, and saying so
promptly is more useful.ANTHROPIC_API_KEY secret and
ANTHROPIC_MODEL variable now govern identification too. Without them, named
scans still process and unnamed ones wait, with the message saying what to configure.They work at different moments and catch different things, which is why there are four:
image.jpg and image-2.jpg rather than letting the second land on
the first. This is not fussiness: iOS repeats filenames for edited photographs, and the
loss was silent — every count agreed while a photograph quietly disappeared.?replace=1). Without it, a same-name file that differs in size takes the
next numbered name (IMG_0001-2.JPG) and the photograph already there is
untouched; a file of the same size is the same file and is written in place. The iPhone
Shortcut cannot ask and sends no flag, so the one path that could overwrite silently no
longer can — which is what a shared uploader with several cameras all counting
IMG_0001 needs (owner’s requirement). Film clips and banknote scans keep
their own naming and are not numbered.What none of them catch: the same photograph in different albums when the file has been re-exported — resemblance matching is deliberately confined to one album, because that is the only place a second copy is a mistake by default.
Comparing an arrival against 24,000 photographs cannot mean reading 24,000 photographs,
so each one’s pixel hash — a short fingerprint of its decoded picture
— is stored once and looked up thereafter. The hashes live in storage, one small file
per album at hashes/<country>/<slug>.json, beside the photographs
rather than in the site’s data: they are worked out from the pictures, so they can
always be worked out again, and they belong nowhere near anything you edit. The
hashes/ and near-dupes/ folders are housekeeping, and every
album picker skips them — for a day they read as albums in the upload droplists
(“hashes” appeared as a country, 25 August 2026), which is fixed and now held
by test.
One rule holds the whole thing up: a photograph’s hash is the hash of the file in storage, never of some half-processed version inside a run. That is why an album imported today and an album fingerprinted years ago can be compared at all.
Three things follow, and they are the answers to the questions this raises:
Owner’s rule: if a photograph being published into an album is 90% or more the same picture as one already there, do not publish it — list it in the email instead.
The measure is a 64-bit resemblance fingerprint: the picture reduced to a small grid of light and dark, one bit per neighbouring pair. Re-encoding, resizing and a trip through a messaging app barely move it; a different composition moves it a lot. 90% of 64 bits means six may differ.
What happens to a match: it is not added to the album, and its file is moved to
near-dupes/<country>/<slug>/ — out of the album, so the
storage count and the site’s count still agree and a later sync cannot
republish it behind your back. Nothing is deleted. The email names each held
photograph, the one it matched, and the percentage.
Two limits, both on purpose:
The honest cost, stated plainly: six bits is loose enough to catch
burst frames and bracketed exposures — two shots seconds apart, which are two
real photographs. That is exactly why a match is held rather than deleted. If the email
names something that should have been published, re-run the import for that album with
direction set to keep-near: it restores every held photograph in that
album and publishes them all.
Every album imported from now on stores its hashes as it goes. The 211 albums already on the site needed one pass to catch up, which is the hash-backfill mode on the pipeline workflow. It reads every published photograph, stores its fingerprint, and emails what it found — and it deletes nothing whatsoever.
| Input | What it does |
|---|---|
country | Empty does the whole site. A country slug narrows it to that country, which is the way to try it on something small first. |
direction | Empty skips every album already stored, so a run that
fails halfway can simply be run again. redo re-reads everything — needed
only if you suspect the stored fingerprints are wrong. |
It is a read of roughly twelve gigabytes from storage and a few minutes of work per hundred albums; storage reads inside Cloudflare cost nothing. Run it after a large delete or move if you want the report re-cut — the fingerprints themselves do not go wrong, and stale ones are ignored rather than believed.
It is telling the truth, and the place to check is storage rather than the site. The Shortcut counts what it actually sent; a batch that reports 0 put nothing in the bucket, so there is nothing half-imported to clean up and nothing to undo — just share the photographs again.
On 24 August a share of 74 photographs to mexico/2026/vallarta reported
none uploaded. Storage held 272 photographs there and the gallery record held the same
272, with nothing added in the half hour around the attempt: the two numbers agreeing,
and neither moving, is what proves the notice right. Nothing was lost, and nothing was
stranded.
The likeliest cause is iOS handing the Shortcut fewer items than were selected, or none, on a large share — the share sheet has its own limits and they are not announced. Sharing in two or three smaller batches gets past it; the upload page in Safari has no such limit and is the reliable route for a big batch. The count on Gallery cleanup is always storage’s own, so it settles what arrived either way.
The opposite of the case above, and a worse one: the Shortcut reported the photographs were uploaded, they really were in storage, and the import still published none of them — while reporting success.
On 27 August four photographs shared to spain/2026/madrid landed as
IMG_5064, IMG_5065, IMG_5066, IMG_5067
— with no .jpeg on the end. The import lists an album’s folder and
keeps the files whose extension it recognises; everything else is a stray. So all four were
counted as stray files, ignored, and the run finished by saying it had imported the
album’s existing 90 photographs. Nothing was lost and nothing was wrong in storage.
The cause is the iOS share sheet, which does not always hand a photograph over with its
filename intact — and the Shortcut builds each object’s address from that name.
It is now fixed at both ends, and neither end needs anything from you.
Two things it deliberately will not do. It will not guess: a file whose
bytes are not a JPEG, PNG or HEIC keeps its name and is reported as a stray, because an
invented extension would put an unopenable file inside an album. And it will not
overwrite: if IMG_5064.jpeg already exists, the nameless
IMG_5064 is left alone and the log says why — a filename on this site is a
promise about which pixels it holds.
Films are not claimed. HEIC photographs and MP4 films share the same file
header, and only a four-character brand inside it separates them; a film branded
isom or qt is left named as it arrived rather than
renamed into a photo album.
The site is static files, so the door lives in the one place on the live domain where
code runs in front of every request: the same Cloudflare Pages Function that answers the
old site’s URLs with real 301s (functions/_middleware.js). Four parts:
/friends/login, functions/friends/login.js)
compares what was typed against the FRIEND_KEY project setting — digests of
both, fixed length, no early exit — and a match earns a year-long HttpOnly cookie
whose value is an HMAC of the key. Nothing readable is stored on the friend’s device,
and rotating the key invalidates every cookie at once. A wrong guess costs a pause and a
plain “that wasn’t it”./friends/ except the sign-in
itself. Strangers are redirected to /friends/, carrying where they were going,
so a shared album link survives the sign-in. Pages served to friends are marked
private, no-store so no shared cache hands them onward./friends/private-paths.json, an
asset the build emits from galleries.json (astro.config.mjs) and the
middleware reads from the deployed site itself — so the door and the site it guards always
come from the same commit. A copy committed beside the Function was rejected: the pipeline’s
bot commits change galleries.json without building, and a stale list fails in
the one direction that matters — serving a hidden album./friends/admin-login, added 2026-08-30): the
“Unlock the live site” button on the admin login card posts the held
passphrase here, top-level; it is verified against the upload Worker’s own
gate (so no second copy of the passphrase is configured anywhere, and wrong guesses
meet the Worker’s delay and lockout), and a match sets the friend cookie and hands
back a first-party page that writes the passphrase into production’s own storage —
door and photo controls in one tap. A wrong passphrase mints nothing and is never
echoed./friends/share-link, added 2026-08-28): types
out the one-tap link — sign-in plus destination album — for the Share button in an
unlisted album’s purple bar. It answers only a request already carrying a valid
friend cookie, refuses any destination that is not a path on this site (a link that signs
someone in and forwards them elsewhere is a phishing kit with the key in it), and the key
never appears in a refusal. The password already travels inside every link the owner
shares, so handing it to the signed-in changes nothing about who can know it.friend_ui) lets every page draw the slim signed-in banner — the
marker grants nothing and is verified against a gated fetch once per browser session, so
a stale one after a password change draws no banner and removes itself. Country pages
weave that country’s unlisted albums into their timeline from the gated
/friends/albums.json (the same component the admin passphrase uses, by a
different door), and woven friends-only photographs carry the purple Friends Only
— Not in Public View flag on tile and photo view alike. Since 2026-08-30 the
Photographs page lists every unlisted album the same way, and the two doors back each
other up: a stored admin passphrase the Worker refuses (rotated since the unlock button
saved it, or the Worker unreachable) falls through to the friend door instead of ending
in silence — before that, a stale key sat in front of a valid friend cookie and
the signed-in owner saw nothing at all. The admin attempt also carries a
five-second deadline: a refusal and an error both fall through, but a Worker that
simply never answers — what a black-holed address looks like from the page —
would otherwise never settle, so the fallback never ran and the page stayed silent for
ever. That is why an album showed at once in a fresh browser (no stored passphrase, so
the friend door answered) and never appeared in the owner’s own.Failure directions, decided one by one. No FRIEND_KEY set → the
gated paths are closed (“switched off”), never open. The path list
unreadable → the last good copy keeps serving; with none at all, unlisted pages serve
(the public site must not go down for this) while /friends/ stays closed,
because that prefix needs no list. A crash anywhere in the decision closes
/friends/ and spares everything public. All of it is asserted in
tests/friends-gate.test.mjs.
Per-photo flags ride the same door (27 August 2026). A public album’s
record can carry a friends list; the site’s data layer strips those
photographs from every page, count and feed before anything renders, and the build
writes them — names, true positions, image addresses — into
/friends/extra/<country>/<album>.json, which sits behind the
gate like everything else under /friends/. The album page fetches it and
weaves the photographs back in for whoever the door admits. The
friend-photos pipeline mode (add / remove, from Gallery cleanup’s
selection) is the only writer of the list; every mode that rewrites an album’s
photographs prunes it afterwards, and a re-import, a sync, a rotation’s rename and
an album move all carry it correctly — asserted in
tests/reimport-keeps.test.py and tests/friend-photos.test.py.
A photo moved to another album arrives public there, and the run log says so.
The boundary, stated once more: the door is on the pages, on
the live domain only. The test site runs no code and has no door, and the photograph files
on images.traveller.org remain ordinary public objects — the 2026-08-19
decision against proxying every image stands. GA is excluded on /friends/;
the unlisted album pages keep their tag.
It didn’t, once. spain/2026/ibiza was created on the phone with the
Unlisted switch on, and imported public — the worst direction for this flag
to fail in. The unlisted choice lives on the album folder’s marker until a record
exists to carry it, and the run’s log shows the choice never made it there: the
likeliest hole was that the folder already existed when the album was created, and
the create answered “already there” while silently dropping the unlisted
request with it.
Two fixes, so the intent survives whichever end loses it:
Ibiza itself was repaired with one Album-details save (hidden on,
2026-08-30). Remember the door’s list on the live site comes from the published
build: a newly hidden album is gated on www.traveller.org from the next
Publish, and is off staging’s lists a minute after the fix lands.
An import re-processes every photograph under the album’s folder, not just the ones that arrived — that is what lets a re-run repair an album. So when a batch lands in an album that already had photographs, “how many did I just upload?” and “how big is the album now?” are different questions, and the email used to answer the first with the second: eight photographs uploaded to Madrid produced “Ready to review: Madrid — 98 photograph(s)” and a body reading “imported: 98”. Nothing was wrong with the import. The sentence was.
The email now says both, and never attaches the word imported to the album’s total:
The zip link says what it holds for the same reason: it is everything the run processed, which on a re-import is the whole album, not the batch. A count that misreports the work is worth fixing on its own — it is also how a real loss would go unnoticed the next time.
An import rebuilds an album’s record from scratch every time it runs. That is on purpose — it is what lets a re-import repair a record that has drifted from storage — but it means anything you set by hand survives only because the pipeline deliberately carries it over.
Three things are carried over, and each is here because losing it would be quiet and wrong:
2026/madrid and set its year to the same, and reported
success. It now reads the album’s own record first. Typing a name or a year still
overrides it, which is how a rename works.If you ever see an album’s path where its name or year should be — on Gallery cleanup, or as a year chip on the phone with a slug spilling out of it — that is this failure, and the repair is one Album details save with the right name and year. Nothing is lost; the photographs are untouched.
The upload Worker serves a second page at /m, built for the phone;
a phone opening the classic page is sent there (?stay=1 keeps the classic
page — it is how the phone reaches the Film and Notes forms). Nothing else changed hands:
/m talks to the same endpoints, the same storage and the same pipeline, and
the desktop page is exactly as it was.
Films are on the phone too (25 August 2026), through the same endpoints as the
desktop form: the clip and a small sidecar of title, place and description are written to
films-in/<country>/<year>/, then the same films
dispatch converts everything waiting for that country. The phone uploads the clip through
XMLHttpRequest rather than fetch for one reason — it is the only way to report how far
a single large upload has got.
Upload and import are one action on the phone. The two-step exists for the
desktop, where a folder drop can raise the “separate galleries or place tags?”
question and the pause lets you check the batch before a pipeline run. The photo library
hands the phone a flat set — no folders, nothing to ask — so when every photo has landed,
/m dispatches the import itself, with the same request the Shortcut and
Gallery cleanup’s Import button send. Same coalescing, same guardrails, one run per
batch. If any photo fails to upload, no import starts: the page holds publishing,
retries only the failures, and dispatches once when the batch is whole — nothing
half-done can reach the pipeline.
The upload Worker reads one file to know what albums and countries exist: the
test site’s gallery-index.json. Everything that lists albums comes
from it — the iPhone Shortcut’s picker, the album droplists on the admin pages, and
the check that refuses an unknown country.
It reads the test site on purpose, and it must not be pointed at the live one.
Production is what was last published; the test site is what the pipeline has actually
done. Import a batch and you get a new album — sometimes a new country — and the
phone has to be able to send the next batch to it straight away rather than after a
Publish. Aimed at www.traveller.org, every album imported since the last publish
would vanish from the picker, and every country created since it would be refused as
“not a country on the site” until you published.
This is also why the album list is the same on both admin sites: they both ask
the Worker, and the Worker always asks the test site. The rule is written into the Worker
beside the address, and tests/worker-mount.test.mjs fails if the default is ever
changed to a production address — the old comment there invited exactly that.
Every address the old site ever had is answered by one of three mechanisms, and it is worth knowing which, because they fail in different ways.
public/_redirects, generated, and deliberately kept
under a hundred rules: the host was observed honouring only the first 100 and silently
ignoring the rest. These cover whole trees
(/burma/2001/photos/content/*)._redirects./journal/?p=15 and 160 others. A
query string is not a file and cannot be matched by the host’s redirect rules, so these
are answered by a map built into the journal index and four lines of script that read the
address and forward. Every inbound link to a letter is one of these, from Google, from
another site, from an old email.Reading a Search Console coverage report (7 September
2026). The owner’s exports from both properties held ~4,000 “not
indexed” rows, and almost all of them are the old site’s URL space draining
correctly: “Page with redirect” and most of “Crawled —
currently not indexed” are Google recording that an old URL now 301s — the
intended end state; those rows shrink on their own as Google digests, and probing a broad
sample against the live site found every one answering correctly. The actionable residue
was small and is fixed: five old URLs the legacy crawl never captured (two
/hongkong/hk-notes-*-old.html addresses, two index-photos.html
pages, /uruguay/1997/index.html, plus the 1992 Zambia letter) 404ed with no
forward — they went into migration/gsc-extra.txt and the generator gave
each an honest target; and Googlebot runs the admin pages’ scripts and tries the
Worker’s endpoint strings as page addresses (/friend-photos,
/rotate-photos…), which 404 correctly but wastes crawl — the
production robots.txt now disallows the eight verbs. Old URLs Google reports
as 404 in future go into gsc-extra.txt the same way; nothing else in these
reports needs acting on.
All three are held by the test suite: tests/redirect-stubs.test.mjs for the
first two, tests/journal-wp-ids.test.mjs for the third — which checks the
map against the migration record entry by entry, checks each destination is a page that was
really built, and then drives three of them in a browser, because a correct map with
the script deleted is still a broken redirect. An id the map does not know lands on the
journal index rather than a 404.
The site reports to Google Analytics 4, property
G-80N129L5BJ, installed 26 August 2026. Before that date it carried no
analytics tag of any kind, which is why any Google Analytics figure for traveller.org
dated earlier than that is measuring the old WordPress site, not this one.
Three kinds of page are deliberately not counted, because none of them is a visitor reading the site:
/admin/ — the tools and this manual. That is
you working on the site. Counting it would put your own pages at the top of every
“most read” report and quietly inflate the totals.The two uploader pages are served by the Cloudflare Worker rather than built with the site, so they never had the tag to begin with.
The exclusion is worked out from each page’s own address, not switched
on page by page — so a new admin page is excluded the day it is created, with
nothing to remember. tests/analytics.test.mjs builds the site both ways and
checks every page of both: the tag on all 745 public pages, and on none of the 13 admin
pages, none of the forwarding pages, and nothing at all in a test-site build.
The tell is the page titles. WordPress wrote them as “Climbing the Pyramids – A Step-by-step Guide | Traveller’s Tales - The Journal”; this site writes “Climbing the Pyramids – A Step-by-step Guide — Traveller’s Tales”. A pipe and a tagline means the old site; an em dash means this one.
http://traveller.org/journal/?p=15 without the www — if that
shows the old site, the apex is still pointing at InMotion, which is the one redirect fault
this shape of report could really mean.Cloudflare Web Analytics was the alternative considered — no cookie banner, no account beyond the one already in use. Google Analytics was chosen for continuity with the old site’s figures (owner, 26 August 2026). The measurement ID is not a secret: it ships in the HTML of every public page, which is how the tag works.
The shelf at /guides is built from a vendored copy of
each app’s guide, held in this repository at src/content/guides/<slug>/
as an Astro content collection. The list of apps is src/data/guides.json;
which of them appear on the shelf, and how many pages each shows, is derived from the
files actually present — an app is listed by being declared, and shown by
having a page.
The sync (.github/workflows/guides-sync.yml running
.github/scripts/sync_guides.py) fetches every non-local app’s folder
through the GitHub contents API with CENTRAL_DOCS_GIT_TOKEN, rewrites that app’s
directory from scratch, and commits the difference — with the same fetch-reset-retry
loop the photo import uses, since your sessions and the import bot push to the same
branch. Three ways in: the Run workflow button, a nightly pass at 06:17 UTC, and a
repository_dispatch of type guides-updated from an app’s own
workflow. A push made with the workflow’s own token never triggers a deploy, so the
run dispatches deploy.yml itself when it pushed something.
It builds before it pushes (2026-09-13). Before anything is committed, the sync rebuilds the whole site with the new guides. If the build fails, nothing is pushed and nothing is published — the shelf keeps its last good copy and the run goes red with the reason. The gate sits before the push rather than before the publish on purpose: a guide that cannot build never reaches the test site either, so your own Publish cannot ship the break. This exists because on 2026-09-12 it did: one app wrote a date without quotation marks, the build failed, the guide published itself anyway, and Cloudflare went on serving the previous version — so every record said “published” while the page answered “not found” for hours.
It publishes itself, carefully (2026-09-07). When a sync commits new guide
pages it also presses Publish for you — but first it asks what else is waiting: it
compares the trunk against production and goes ahead only if every file
waiting is under src/content/guides/. If anything else is pending —
a site change, another session's work, even a change to the shelf's own layout — it
leaves everything on staging and says so in the run, because this publish moves the whole
trunk head and would take that work live with it. The decision is
.github/scripts/guides_only.py; the publish it dispatches carries the exact
commit it vetted, so a push landing in between is refused rather than shipped.
The addresses appear only on the live site (2026-09-14). Six cards now carry the
app’s own address, including ones that were withheld while the shelf was open —
they are safe there only because the live shelf sits behind Cloudflare Access. The
test site on github.io cannot have that door (it is GitHub Pages), so a
staging build renders the cards and strips every address. If the Access application
is ever removed, take the addresses out of src/data/guides.json in the same
change — the door and the addresses are one decision, not two.
The shelf is a directory as well as a bookshelf (2026-09-12). Each card carries
a sentence saying what the app is, and — where the app has an address a person can
open — a link straight to it. The link comes from url on the
app’s row in src/data/guides.json; a row without one shows no link, and
that is deliberate rather than an omission: several of these apps are protected by
nothing but an unguessed address, and this shelf is readable by anyone holding its own
address. tests/guides.test.mjs asserts that only the rows naming an address
render a link, so one cannot be added by hand without the manifest saying so.
An app can be held (2026-09-10). A hold line on its row in
src/data/guides.json takes it off the shelf entirely — no card, no side-nav
row, no pages built, nothing fetched — while its guide stays in its own repository.
It is for an app being rebuilt fast enough that a guide would be wrong before it was
read; the first one held was the Amapas 353 website, whose guide went stale between
drafting and pushing. Held apps are not counted among those “waiting for their
first guide”: they have one.
Guardrails, each one proven by tests/guides-sync.test.py:
title gets one (first heading, else the file name), so
another app’s carelessness cannot fail this site’s build..md and image files are copied; a path that climbs out of the
app’s own directory is refused and named in the log.CENTRAL_DOCS_GIT_TOKEN: the run ends green having done nothing, and says which
table to consult.Each app carries a colour (2026-09-07): the band its name sits on across the
shelf, and the edge of its row in the side nav, which lists every app from every guide
page. The colour is color in src/data/guides.json, and an app
that names none is given one by position from the ramp in
src/lib/guides.js — so a new app is never bandless and never inherits
its neighbour’s colour.
What the pages do (tests/guides.test.mjs, driven from the built
site): shelf cards and counts derived from the collection; landing and page routes with
the app’s pages down the side and previous/next underneath; a relative image in the
markdown goes through Astro’s image pipeline and is served; every page is
noindex, none reaches the sitemap, the live robots.txt disallows
/guides/; no guide page carries the admin nav or login widget — nor, since 2026-09-06, the public site’s own nav: the owner made the shelf a standalone area whose design need not match the site, and the same change moved its code panels onto a light ground.
The door is a second Cloudflare Access application on
www.traveller.org/guides*, separate from the admin one so its allow-list can
grow to an app’s users without opening the admin to them. Deferred: the owner
chose on 2026-09-05 to publish the shelf open, so today there is no door on either site
and every guide is written as if public; the runbook’s open list carries the recipe
for when one is wanted. As with /admin, the test site on
github.io could never have one.
Since the DMARC record went up on 19 August 2026, every large mailbox provider that
receives mail claiming to be from this domain sends back a daily machine-readable report:
which servers sent it, and whether each one proved the claim. They arrive at
dmarc@traveller.org as zipped XML, they are unreadable by eye, and they are
almost always fine — which is exactly why a person stops opening them and misses the
one that is not.
So the traveller-dmarc Worker reads them and stays silent. You hear
from it only when there is something to do. Three things make it speak:
What it deliberately ignores: one of the two checks failing. DMARC passes on DKIM or SPF, and a forwarded message routinely breaks SPF while its DKIM survives — which is what this domain’s own Cloudflare Email Routing rows look like every day. Alerting on that would cry wolf daily and train you to ignore it.
The first reading, 10 September 2026: five reports (Google and Outlook, 31 August – 3 September), 24 messages, every one passing, from exactly two senders — Resend (13: import notices and the contact form) and Cloudflare Email Routing forwarding to Gmail (11). Nothing unrecognised, nothing failing. That is what a healthy week looks like, and it is the baseline the Worker now watches against.
To read a pile of them by hand (a backlog, or a sanity check on the
Worker): python3 .github/scripts/dmarc_check.py ~/Downloads/*.zip — it
takes .zip, .gz and .xml, applies the same rules,
and prints either the findings or “nothing to do”. Both readers are held by
tests/dmarc.test.mjs, which drives the Worker’s own code over the five
real reports and over synthesised ones carrying each alarm.
Where the reports go, and why the switch is an edit rather than an
addition. dmarc@traveller.org has existed since cutover step 15, forwarding
to brarob@gmail.com — the DMARC record’s
rua=mailto:dmarc@traveller.org would bounce without it. Pointing it at the
Worker means editing that rule’s action on the
routing rules
page, exactly as theboys@ already does. A routing rule has one action, so
the Worker then becomes the only recipient — which is why an alert forwards the
original report with it. The forward needs no credential and the written alert needs the
Resend key, deliberately: an unset key costs the explanation, never the evidence.
Reports arrive in two different envelopes, and the reader handles both
(11 September 2026). Google does not send a multipart message: the mail
is the zip — Content-Type: application/zip at the top level, no
MIME boundary anywhere, the file name folded onto a continuation line, the whole body being
the attachment. Other reporters do send multipart. The first version of this Worker looked
only for boundaries, so the first real Google report it received was declared empty and
raised a false “could not be read” alarm on a perfectly clean report. The fix
reads both shapes, and tests/fixtures/dmarc/google-single-part.eml keeps a
copy of the real envelope so the suite tests what arrives rather than what was assumed.
If a report arrives forwarded but with no explanation beside it, the reading worked and the telling failed — the Resend key was refused, or is unset. The Worker cannot email you about a broken emailer, so it writes the reason to its own log: open traveller-dmarc and read the recent invocations. The forward happens first and needs no credential, which is why the evidence still reaches you when the explanation cannot.
The policy is still p=none — monitor only, nothing
blocked. Moving to p=quarantine is what actually stops somebody sending as this
domain, and the reports are the evidence for doing it safely: once several months have gone
by with every legitimate sender passing, the change is low-risk. Do not make it while
anything is still failing.
You asked for a way to take photographs straight from iCloud Photos on Windows — the
Copy iCloud Link kind, share.icloud.com/photos/…, as in the email
Photos sends — without downloading them first, and with the same sizes the site serves
today. The size half is already settled: the import normalises every photograph to a
2048px long edge, so any source that is at least that big comes out identical. The open
half is whether a machine can read the link at all. Apple documents no API for those links,
they expire after about a month, and the sandbox this site is built in cannot reach
share.icloud.com — so the question is answered by a GitHub runner
instead of by guessing.
The probe is a workflow, not an intake mode. icloud-probe.yml takes
one link, tries to list the photographs behind it and download one original, and prints what
it found at each of six stages. It writes nothing anywhere — not the bucket, not the
repository, not the site — and it needs no credential, because the link is one you
already made public. It never prints a download address whole, since the signed part of one
is a key to whoever holds it.
VERDICT line. Assets enumerated plus a stage-6 pixel size above 2048 means originals are reachable and a paste-the-link mode is a modest build. Not settled means the guessed endpoint did not answer, and stage 3 — the names the page’s own JavaScript calls — is where the next attempt starts.Result, 13 September 2026 — it passed, on the third run. Your San Andrés
link (12 photographs) resolved without any credential; the answer carried a short-lived
anonymous token (twenty minutes) that opens the link’s shared zone; one call then
listed every photograph with its original’s dimensions, file type and size; and the
original downloaded whole — 4032 × 3024, 3.5 MB, EXIF intact,
straight from Apple’s content servers. That is the full-pixel file the phone took, not
a re-encode, so the site’s 2048px tier comes out exactly as it does from any other
upload. Three things the run recorded that the intake must respect: the link carries its own
expiry (this one, 13 October 2026), two of the twelve names repeat inside the one link
(IMG_2428.JPG twice, at different sizes — two library items, one name),
and two of the twelve are 2048px files in the library itself, which is the size they were
saved at, not anything the link did. You gave the word the same day; the mode is the next
section.
How to use it is on the owner page;
this is what the machinery does. The pipeline gained an icloud mode: given a
Copy iCloud Link and an album, it fetches the originals into the album’s folder in
the bucket and then runs the ordinary import on that folder — the import never knows
the photographs did not arrive by upload. Three parts:
/dispatch, kind icloud) takes the link from
either upload page, checks its shape, resolves the destination the way it does for any
batch — the desktop’s country + slug + name + year + Unlisted tick, or the
phone’s album address, lower-cased and snapped onto the site’s roster like a
Shortcut path — reads the folder marker’s unlisted choice, refuses a country
the site does not hold unless the desktop page declared it, and dispatches the run. It
does not fold into a waiting import the way an upload’s dispatch does: a
waiting import lists storage, and these photographs are not in storage until this run
fetches them..github/scripts/icloud_fetch.py, called by
r2_import.py before imprt()): resolves the link without any
credential; takes the twenty-minute anonymous token the answer carries; lists the zone
behind the link in one call per page; and downloads every master’s original by its
signed address, keeping Apple’s filename and case. Names are planned against the
folder, not only within the link: an original already there at its declared size is
kept, not fetched again (the same photograph in a second link, or the same link twice);
a different photograph wearing a name the folder holds is numbered on until the name is
free (IMG_2427-2.JPG), so nothing is ever written over; a movie is skipped
and named; a download refused because the token or the address aged out resolves again
once and retries. The import’s pixel-hash duplicate detection then runs on the
folder as on any batch, which is what catches one picture under two names. An expired link, or one
that is not a photos link, fails the run in a sentence, which is what the failure email
then says. No token and no signed address is ever printed.The link rides in the dispatch’s files input (the fixed set of inputs
was not worth widening for one URL). Run by hand from the
Actions
page: mode icloud, country, slug, and the link in files. Proven
by tests/icloud-fetch.test.py (Apple and the bucket stubbed with the shapes the
probe recorded), tests/icloud-intake.test.mjs (the Worker route, both spellings,
every refusal) and tests/icloud-pages.test.mjs (both pages driven, the posted
payloads pinned). What Apple could change without notice is the whole of the recipe: if a
fetch starts failing at the resolve or the listing, the probe workflow is the first thing to
run, and its stage 3 is where the new shape will be.
The two zero-code routes remain: iCloud for Windows syncing originals to a folder you drag into the uploader, and the link’s own Download button. You set both aside — the first because iCloud for Windows lives on another machine, the second because it is too many steps per batch — which is why the probe exists.
What answer engines and search crawlers are given, in one place — including what is deliberately not built, so its absence reads as a decision rather than an oversight. A plain-language version of this section, for whoever is looking after the site rather than building it, is on the shelf as How the site is found — SEO & AEO (owner, 2026-09-07).
/llms.txt (site map + summaries) and /llms-full.txt (full journal text, gallery/country/currency descriptions, place lists) regenerate on every build — they can't go stale.SITE_URL, the same variable that steers the rest of the build.
Verified 2026-08-18: a production build's copies contain zero test-host references and
450 www.traveller.org ones. Nothing about the cutover needs doing to them
— the launch runbook's post-cutover check (step 20) opens /llms.txt
to confirm it.robots.txt: the test site blocks all
crawlers; production allows everything except /admin/. There are no
AI-crawler blocks (GPTBot, ClaudeBot, PerplexityBot and kin are welcome) — being
readable by answer engines is the point of publishing llms.txt, so blocking
them would defeat it. If that ever changes, this file and robots.txt.js are
where the decision lands./sitemap.xml — deliberately the same
filename the old site used, so Google discovers the new one with no Search Console
change) regenerates on every build
— every public page, /admin/ excluded, and the production
robots.txt names it. Built 2026-08-18, after this manual was caught
promising one that nothing generated.og:url, og:image (every page; a
gallery shows its first photograph, everything else the home carousel's) and
schema.org JSON-LD — WebSite + Person on the home page, ImageGallery with a
derived count on each gallery, BlogPosting with a machine-readable date on each journal
entry. All derive from the data files and SITE_URL, and
tests/seo.test.mjs fails the build's shape if any of it goes missing.