Admin / Site manual Back to the site

Publishing & image pipeline

The operating manual for traveller.org — kept current with every publish that changes a process. Sits with the CMS portal behind the admin door.

The publishing pipeline — staging first, live when you say

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
  • Every commit — yours, Claude's, a photo import — reaches staging on its own. Nothing reaches the live site until you press Publish on the Publish page, which also lists exactly what is waiting. (Until cutover, "live" means the production branch moves and nothing more — Cloudflare Pages starts watching it at cutover step 1.)
  • Every publish is a fast-forward of the production branch: attributable, diffable, revertable. Rolling back is one revert on staging plus one more Publish.
  • Deploy status lives in the repo's Actions tab. A red ✗ means the build failed and the site simply kept its previous version — a failed publish can never half-update the site.
  • The Publish button needs the same Worker GITHUB_TOKEN as the Start import button — the callout below covers setting it up once for both.

The three image tiers

TierSizeLivesServes
Thumbnails460 pxrepo (public/thumbs/) for legacy galleries; R2 (thumbs/…) for upgraded onesgallery grids, cards, strips
Large (legacy)800 pxold InMotion server (hotlinked)lightbox, for galleries not yet upgraded
Large (high-res)2048 pxCloudflare R2lightbox, 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.

High-res intake — upload to the bucket, then one click

The R2 bucket is live: traveller-images, public base pub-d86b555b7dd7462abd871c743192bcd6.r2.dev. Per batch:

  1. Upload the gallery's 2048 px re-exports (originals fine — they're downsized bucket-side) under <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.
  2. Optional but recommended: place-named folders one level below the gallery path — vietnam/2009/halong-bay/Cat Ba/…. Photos arrive place-tagged; the gallery groups itself into city sections.
  3. When the upload finishes, click Start import → right there on the page — it dispatches the R2 gallery import workflow directly on GitHub Actions, no chat step needed. (If the worker's optional 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.
How the passphrase door behaves under attack (and under a typo): the Worker's address is public and Cloudflare Access never covers it — Access guards 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.
Where the keys live: the R2 credentials exist only as GitHub Actions secrets (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.
If the button says "Automatic start is not set up on this worker yet": that is the fallback firing because the Worker has no 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 tokensGenerate 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 SecretsAdd → 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.
If the button says "Could not start automatically": different problem — the token is installed and the Worker did call GitHub, but GitHub refused. The page prints GitHub's own reply underneath the message; read it before changing anything:
  • 404 / Not Found — the token cannot see this repository at all. Either it is a classic token without the 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".
  • 403 / Resource not accessible by personal access token — the token sees the repo but lacks Actions: Read and write. Read-only is not enough: starting a workflow is a write.
  • 401 / Bad credentials — the value is wrong, truncated, or expired. Re-copy it; a trailing space or newline is enough to break it.
Fastest fix in all three cases: generate a fresh fine-grained token scoped to only 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.

"A fix isn’t showing on the upload page"

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.

What emails, and what does not

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.

The review email

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:

  • The album on the test site, as a link you can open on the phone that took the photographs. This is the whole point: an upload lands on staging and nowhere else until you press Publish, and nothing used to tell you it had arrived.
  • A reminder that it is not live, with the link to Publish.
  • Everything else still waiting to be reviewed, each with its own link. That list is derived, not tracked: the live site builds from the 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.
  • The zip record, and anything skipped as unreadable or duplicate, exactly as before.

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.

How soon does a new album appear in the uploader’s list?

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.

Locator maps

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 twice

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.

Unreadable photographs

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.

Phone photos: orientation and HEIC

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.

Duplicate photographs

Three things stop the same picture appearing twice, and it is worth knowing which catches what:

  • Same filename, same album — the upload simply replaces the old file, so re-uploading a batch never duplicates it. The upload page tells you how many files will be replaced before it starts, so a batch aimed at the wrong album is obvious.
  • Same picture, different filename — caught at import. Every photo is hashed on its decoded pixels after resizing, so 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.
  • Same picture, re-exportednot caught. A fresh export from Lightroom has different pixels, so it reads as a genuinely different photograph. Catching that needs perceptual matching, which brings false positives on bracketed and burst frames; the gallery cleanup page is the tool for those.

Getting the country wrong — or genuinely new

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.

Moving a whole album to another country

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).

Moving photos between albums

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.

Re-developing flat scans (tone)

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:

  • Neutralise — a scan that is grey with a tint is converted to true grey, which removes the paper colour. A frame that carries real colour is detected and left in colour: each channel of a genuine black-and-white scan is the same picture at a different scale, so normalising the three makes them coincide, and a colour photograph's three channels stay apart.
  • Black and white point — the histogram is re-anchored so the darkest tones reach 0 and the brightest reach 255. A fraction of a percent is clipped at each end first, or a single dust speck would define the endpoint and nothing would move.
  • Midtone S-curve — a gentle contrast bend that restores the snap scanning flattens. 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.

Shrink big photos — and why it is on by default everywhere

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.

Pinned cards, strips, the hero carousel — and the journal panel

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.

Which version of the upload page you are on

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.

What titles a gallery

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-bayHalong 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.

Subfolders: galleries, places, or neither

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.

Uploading albums back to back

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.

Credentials & where to update them

WhatWhere it's storedWhere to change it
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_ACCOUNT_ID
GitHub Actions secretRepo → Settings → Secrets and variables → Actions — Cloudflare side: Cloudflare dashboard → R2 → Manage API Tokens to roll the token
RESEND_API_KEYGitHub Actions secretRepo → Settings → Secrets and variables → Actions — Resend side: resend.com → API Keys to roll the key
TURNSTILE_SECRET
RESEND_API_KEY (contact form's own copy)
Cloudflare Worker secrets on traveller-contacttraveller-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-emailDeploy 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-uploadDeploys 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 secretCreate 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 useGitHub Actions secret + GitHub Actions variableKey: 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 secretGo 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 rightSettings → then scroll to the very bottom of the left-hand list, where Developer settings is the last item → Personal access tokensFine-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 secretsFRIEND_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.

Removing photos — the sync mode

Photo removal (the photo view's Remove from gallery button, or the Gallery cleanup page) is a two-step machine:

  1. The Worker deletes the large and its thumbnail from the bucket immediately (routes GET /photos to list a gallery's bucket contents and POST /remove-photos to delete — both passphrase-gated like everything else).
  2. It then dispatches the import workflow in sync mode: re-list the prefix, rewrite the gallery record to match the bucket, prune place tags for gone files, clear a stale home cover — no image processing, so it runs in seconds and the site redeploys a few minutes later. Sync keeps the gallery's stored year and name, no-ops when nothing changed, and refuses to sync a gallery down to zero photos (removing an entire gallery is a deliberate act — ask Claude).
Deletion is permanent. Nothing is archived — that's the point (storage space). The batch zips are the record of what was imported; download them before heavy cleanup if you want a safety copy. If the sync dispatch fails (e.g. the Worker's 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.

Films — video intake

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.

Originals: the source AVI/MOV goes into the emailed zip record together with the converted MP4, then is deleted from the bucket — the same keep-nothing- twice policy as RAW photos. If a conversion fails, that clip's original stays in 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).

Editing an album's details — the edit-album mode

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.

Publishing order — the reorder and edit-films modes

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.

Editing and creating a letter

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.

Which photograph illustrates a journal entry

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.”

  1. The owner's pick from Journal photos, held in 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.
  2. Otherwise the letter’s OWN first picture — one placed in the Journal editor (stored as a bucket key, shown as its thumbnail) or one that came with the letter from the old site (already in 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.
  3. Otherwise a photograph of that country AND that year, taken a third of the way into the album. Deterministic — no randomness — so an entry keeps the same picture from build to build. Only the same year: the rule used to fall back to any gallery of that country when no year matched, which is how a 1992 letter could be illustrated by a 2016 photograph.
  4. Otherwise the country’s locator map, and for the two entries with no country at all, nothing. A map says “no photograph” honestly; another year’s picture does not.

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.

Currency scans — the three tiers

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:

TierSizeKeyServes
Large3000 px long edgecurrency/<country>/<name>.jpgthe note-page lightbox
Stage920 px widecurrency/stage/…the note page (sharp at 460 CSS px on 2× displays)
Thumb320 px widecurrency/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.

Finding the note on its dark ground

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:

  • Thresholds adaptively (Otsu) instead of at a fixed brightness — “dark ground” is a relationship, not a number, so a dim exposure or a charcoal cloth separates as well as true black does.
  • Reads colour, not brightness. Detection uses each pixel's strongest channel: a deep blue or red panel running to the note's edge is dark by luminance, and a luminance threshold shaves the corner off it.
  • Takes the dominant object, not the extent of every lit pixel. One dust speck or lens flare in a corner used to widen the box to the whole frame — a silent no-op that looked like success.
  • Straightens a hand-held tilt up to 8°, by the rectangle's own definition: the angle whose bounding box is smallest is the angle at which the note is square. Without it a 3° tilt leaves a black wedge in all four corners.

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-sitegithub.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.

Two things that must stay true. 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.

The currency thumbnails are now checked out on every run, not only the 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.

How an admin sees an unlisted album on a public page

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.

Deleting named files — prune-files

prune’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:

  1. The record. A key any gallery publishes is refused outright, whichever way it was asked for. The site would otherwise render broken frames and still count them. Deleting a whole album is delete-album; one photograph is Remove on its own page.
  2. The content, on a sweep. Every candidate is checksummed against every photograph the site publishes in that country. One whose bytes exist nowhere else is the last copy of a photograph. A "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.

“Asks the bucket” means walking directories, never photographs (2026-08-20). The first version paged through every object in storage — about 46,000 of them, 47 sequential calls, ten to twenty seconds — and since every interactive admin page asks /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.
Every one of those listings is now read to the END (30 August 2026). A listing answers with one page, and this walk asked once and believed the answer — so the top-level listing returned only the countries it reached before that page filled, and each level took its sub-directories from a first page too. On a bucket of ~46,000 objects that quietly loses whole countries late in the alphabet, and an unlisted album of theirs is found by nothing else: a public album comes from the site’s own index, but an unlisted one exists for the uploader and Gallery cleanup only if this walk sees it. That is why the owner could open his unlisted Ibiza album on the site all day and get “0 albums match” for it in the album picker — and why it looked like a fault in the album rather than in a listing. Spain is deep in the alphabet. The walk still lists directories only, so it stays as fast as it was; it simply follows the cursor to the end now. 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.

The order of those two matters, and got it wrong once. Tidying the trailing slash away before testing for it turns “no filename” into a perfectly ordinary folder-and-file and stores the photograph under the album's own name — the silent overwrite the refusal exists to prevent, arriving through the fix for it. The endpoint tests for the empty filename first, then tidies.

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.

AI-drafted write-ups ride behind every scan batch

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):

  • The write-up publishes as it is written (changed 2026-08-19). It goes onto the note page immediately and is stamped 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.
    The 214 that were waiting were cleared in one go on 25 August 2026, at the owner’s word. Not a word on the site changed — they were already published; what went was the queue. Their 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.
  • Drafting never sinks a scan run. The write-up step starts only after the tiers and records are published; any API failure is a warning in the log, and the Draft write-ups now button on the review page (the note-writeups mode) retries any note left behind.
  • No scan, no draft. A note still on its 100 px legacy thumbnail is refused by name — there is nothing to read — and the review page lists those separately so the queue is visible, not hidden.

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.

Moving a batch that landed in the wrong place

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/2009vietnam/2009/halong-bay).

Splitting one album back into several

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.

Zip records

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.

Zip notification email: sent to brobinson@traveller.org from 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.

Small assets — the shared Drive folder

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.

The full archive (the ~133 GB of originals)

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.

A country you just created, and the next batch (23 August 2026)

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.

An album with no photographs in it (23 August 2026)

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.

  • It is not a photograph. The import only ever processes image files, so the marker cannot reach a gallery page, and the album’s photo count subtracts it — a newly created album reads 0 photographs, which is the truth.
  • It shows as ◌ not imported, like any folder in storage with no gallery record, because that is exactly what it is.
  • Nothing is published until real photographs are imported into it. An empty album has no page, no listing and no place in any count.
  • An existing album is never overwritten — asking for one that is already there is reported as “already there”, and the rest of the list is still created.

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.

Naming a photograph the reader could not place (13 September 2026)

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.

A note is never published standing on end (13 September 2026)

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.

Unnamed banknote photographs: identification in post (25 August 2026)

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.

  • Certainty is the gate. The model is told a wrong answer publishes a mislabelled banknote while an uncertain one just waits; anything not certain — and any API failure, refusal, or unreadable photograph — stays in auto/, with a warning per file in the run log saying why. Nothing is guessed onto the site.
  • The model sees the site’s own roster — country names and the note ids each country already holds — so an existing note matches its own id, variant included, rather than inventing a near-duplicate.
  • The answer is checked against the intake grammar before anything is renamed. A denomination or variant that would not parse downstream holds the file rather than producing a mis-named scan — which is not theoretical: the first real note came back with the variant ^0, and was held.
  • The model reads the note’s geometry, because measurement cannot (25 August 2026). The measuring cropper refuses any frame that does not look like one note on a plain ground — a hand, a reflection, clutter beside the note — and its refusal used to publish the raw frame: the first production test shipped the euro 10 back sideways and uncropped. And no measurement can tell sideways from upright at all; a rectangle has no reading direction, but a model reading the note’s own text can. So every answer now carries the note’s bounding box and its rotation (0/90/180/270° clockwise to stand it upright); the pipeline cuts and turns by them, and the measuring cropper then fine-trims inside that rectangle, which is the job it is good at. A box that would mis-cut — inside-out, or under 4% of the frame — holds the file instead of destroying a photograph that cannot be re-taken.
  • An overloaded API is retried, not surrendered to (25 August 2026). The first real upload came back 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.
  • Same credentials as the write-ups: the 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.
  • A country new to the whole site is held, not created — it needs a region and a display name, which the classic page asks for and a photograph cannot supply.

The four things that stop a photograph being duplicated (25 August 2026)

They work at different moments and catch different things, which is why there are four:

  1. Within one batch, by name. Both upload pages now file two same-named files as 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.
  2. Against the album, by name, before uploading. Both pages list what is already in the album and ask before replacing anything. Answering Replace replaces, which is often the intention — and since 13 September 2026 that answer is the only way a same-name upload can overwrite: the page says it to the Worker per photograph (?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.
  3. Across the album, by picture, during the import. The pipeline hashes each photograph’s decoded pixels after orientation and resizing, so a renamed copy, a copy whose metadata was rewritten, and the same original sent twice under two names are all caught — and the loser is deleted from storage and named in the email.
  4. Across the whole site, by picture, during the import. The same hash, compared against every photograph the site publishes — so a photograph that is already in another album is recognised the moment it arrives. This one reports and does not delete: the email names both copies and both stay where they are. See below.
  5. Within one album, by resemblance, during the import. A photograph that is 90% or more the same picture as one already in that album is not published — it is held in storage and named in the email. This is the one that catches a copy whose pixels genuinely differ: a re-export, a screenshot, a copy that has been through a messaging app. See below.

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.

How the site-wide check knows (25 August 2026)

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:

  • Nothing is deleted across albums. The same photograph in two albums may be exactly what you meant — a picture that belongs to both a city album and a retrospective. The import email says is already published as … and names the other album; removing either copy is a decision you make on that album’s page.
  • Old hashes cannot lie. Nothing tidies the store up when an album is deleted, moved or pruned. It does not need to: every comparison is made against the published record first, so a fingerprint left behind for a photograph the site no longer shows is simply never consulted.
  • An album with no stored hashes takes no part. It is not an error — it means that album has not been through the backfill below, and the import says so in its log.

The same photograph, not the same file — held, never deleted (25 August 2026)

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:

  • Only within one album. The same picture in two albums is still reported and never touched.
  • Only a photograph that is not already published. The rule is about what is being published now. Re-importing an old album never withdraws photographs that have been on the site for years — that would be a change nobody asked for and nobody would see.

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.

Fingerprinting the albums that came first — the backfill mode

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.

InputWhat it does
countryEmpty does the whole site. A country slug narrows it to that country, which is the way to try it on something small first.
directionEmpty 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.

When the phone says nothing was uploaded (24 August 2026)

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.

A photograph with no file extension on its name (27 August 2026)

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.

  • As it is stored. The upload itself says what it is: an iPhone sends a photograph with its own content type. When the address ends in a name with no extension, the upload Worker takes the extension from that content type and stores it under the corrected name. A name that already has an extension is never touched, whatever the upload calls itself — the filename is the photograph’s identity on the site.
  • As it is imported. Anything already sitting in storage without an extension is read — the first bytes of a file say what it is whatever its name says — and renamed in the bucket before the import decides what is a photograph. The run log warns that it happened and the review email lists each rename, so it is visible rather than magic.

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 friends door: how one password guards the unlisted albums (27 August 2026)

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:

  • The lock (/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”.
  • The gate in the middleware checks that cookie on every request to an unlisted album’s pages and to everything under /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.
  • The list of what is private is /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.
  • The admin unlock (/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.
  • The share mint (/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.
  • What a friend sees (reshaped 2026-08-28, owner: no friends page). Signing in lands on the home page; a second, readable marker cookie (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.

An album created unlisted must import unlisted (30 August 2026)

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:

  • The album builder no longer drops the ask. Creating an album that exists is a no-op; asking for it unlisted is an instruction. The marker is now (re)written with the unlisted mark even when the folder already exists, and the answer says “already there — now marked unlisted for its import”.
  • The import reads the marker itself. On a first import with no explicit hidden answer, the pipeline asks the marker’s own metadata — so a dropped dispatch field, or a run started by hand from the Actions tab with the hidden box blank, can no longer publish an album that was created unlisted. The log says when the marker was the source. Only on a first import: once a record exists it is the truth, and a stale marker must not re-hide an album you deliberately unhid. An explicit unhide on the run still beats everything.

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.

Two numbers in the review email (28 August 2026)

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:

  • Adding to an album: Ready to review: Madrid — 8 new (98 in the album), and the body opens “8 new photograph(s) imported — the album now holds 98.”
  • A first import: one plain number, as before — both questions have the same answer and saying it twice would be noise.
  • A run that adds nothing (a repair, or Import pressed twice): “… none of them new”, and the body says every photograph was re-processed rather than claiming an import.

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.

What a re-import keeps, and what it rebuilds (27 August 2026)

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:

  • The album’s name and year. Added 27 August 2026. Re-running an import from the Actions page with only the country and the album path filled in used to fall back to the path for both — so a run whose purpose was to add three photographs to Madrid renamed the album to 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.
  • The unlisted flag. Dropping it would publish an album you had hidden — the worst direction for a default to fail in. An explicit hide or unhide still wins.
  • An order you arranged by hand. Photographs that are still there keep their positions and new ones are appended, rather than the whole album resorting itself.
  • The friends-only list (added the same day as the name and year, for the same reason). Losing it would publish exactly the photographs you marked friends-only, and report success. Entries whose photograph left the bucket are pruned rather than kept as ghosts.

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 phone’s own upload page, and one action instead of two (24 August 2026)

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.

Which site the phone and the admin pages read (23 August 2026)

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.

  1. Wildcardspublic/_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/*).
  2. Stub pages — every exact old URL is built as a tiny page that forwards. 174 of them. This is why the redirect map is not simply pasted into _redirects.
  3. The WordPress permalinks/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.

Google Analytics, on the public pages only (26 August 2026)

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:

  • Everything under /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 whole test site (brarob100.github.io). It is browsed constantly while work is going on, and on a site of this size those visits would be a large share of the numbers.
  • The forwarding pages for old URLs — 622 of them. They redirect on sight, so counting one would record a visit to a page nobody read.

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.

So a Google Analytics report of old-site pages is not by itself evidence of a redirect fault. It is either traffic from before the cutover, still inside the report’s date range, or somebody genuinely reaching the old host. To tell them apart: check the report’s date range first (the cutover was 19 August 2026), and open 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 guides shelf: how an app’s guide reaches /guides (5 September 2026)

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:

  • A page without a title gets one (first heading, else the file name), so another app’s carelessness cannot fail this site’s build.
  • Only .md and image files are copied; a path that climbs out of the app’s own directory is refused and named in the log.
  • A page deleted upstream is deleted here; a folder that has gone (GitHub 404) takes the app off the shelf rather than leaving a stale copy; images without a page are not a guide. A GitHub error other than 404 fails the run instead of reading as an empty folder.
  • The local app (this site’s guide) is never written or removed by the sync.
  • No 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.

DMARC: who is sending as traveller.org (10 September 2026)

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:

  • Both checks unaligned on a message claiming this domain — it proved neither DKIM nor SPF. Either a service nobody configured, or somebody using the domain’s name.
  • A receiver stricter than the published policy (a message quarantined or rejected while the policy asks for none).
  • A report it could not read. Deliberate: silence has to mean “checked and clean”, never “broke quietly”, so a parse failure is itself an alert.

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.

Photographs from an iCloud link: the feasibility probe (13 September 2026)

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.

  • To run it: Actions → iCloud link probeRun workflow, paste the link. The run is named after the link.
  • To read it: the job log ends with a 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.
  • What it does not do: import anything. If the verdict is good, the intake mode is its own change set, with the phone page, the desktop page and the pipeline’s dispatch inputs all checked, and its own entry here.

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.

Photographs from an iCloud link: the icloud mode (13 September 2026)

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:

  • The Worker (/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.
  • The run (.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 import is unchanged: 2048px larges, 460px thumbs, HEIC converted, duplicate detection, the review email — then the deploy.

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.

LLM & search plumbing (automatic)

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.
  • Both flip their addresses at cutover on their own. Every URL in them derives from 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.
  • Meta descriptions and Open Graph tags (title + description) are generated per page from the data files.
  • Crawling policy lives in 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.
  • The sitemap (/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.
  • Canonical tags, 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.

Costs of the whole machine

  • Test phase: GitHub Pro (already in place) covers repo + Actions + Pages.
  • Production: Cloudflare Pages $0 · R2 ~$0.25/month for web derivatives (~$2.25/month if the full originals archive lives there too) · Decap CMS $0 · Cloudflare Access $0.
  • After the redirect overlap ends, the InMotion plan can be cancelled.