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