v3.9.0 — Collections, add-to-cart, admin i18n, origin hardening
Two features that had been silently missing for six or seven minor versions, admin translations that never worked, and four defects from a post-merge hunt.
🛍️ Two placeholders that outlived their release
Both had complete backends — only the UI was missing.
Collections admin
/admin/shop/collections said "Collections ship alongside the product catalog in v3.1" — at v3.8.1. Seven minor versions late. The backend had been there the whole time: 3 tables, service methods, a live public API.
Now a working page: list with localized title / status / product count, and a create form with EN+TH titles, optional slug, status, and product assignment.
Add to cart — the more serious one
The product page footer read "Cart + checkout ship in v3.2 … Currently browse-only."
That was wrong and customer-facing. /cart and /checkout both worked and the API accepted POSTs — but nothing on the product page was ever wired to them, so the shop could not take an order at all.
The button now surfaces the server's real error (out of stock, unknown variant) rather than a generic failure, disables while in flight so an impatient second click can't double-add, and links to the cart on success.
🌏 Admin was pinned to English
The CMS ignored the TH toggle, Accept-Language, and the PARAGLIDE_LOCALE cookie.
bindingsHook derived locale only from the URL's first path segment. For /admin/… that segment is "admin" — not a locale — so it fell back to DEFAULT_LOCALE on every request. AdminLocaleToggle wrote the cookie and reloaded, but nothing read that cookie, so the toggle wrote into the void.
Locale is now surface-aware — (www) from URL (SEO-visible, shareable), (admin) from cookie (validated against SUPPORTED_LOCALES). All 348 cms_* keys already had Thai translations; they had simply never been reachable.
PARAGLIDE_LOCALE=en → Dashboard
PARAGLIDE_LOCALE=th → แดชบอร์ด
🔒 Origin hardening
⚠️ Behavioural change. Non-browser clients posting to/api/shop/cartor the checkout endpoints without anOriginheader now get 403. Browsers are unaffected — they have sentOriginon every POST since ~2020.
A missing Origin used to pass the guard:
if (!origin) return null; // same-origin fetch usually omits itA decade out of date. The real defect: cart/discount already rejected this case — two copies of the same security check had silently diverged.
Checkout had no guard at all. checkout/start (creates orders) and checkout/pay (creates charges) were unprotected while the lower-stakes cart routes were guarded — inverted priority. cron/sweep (CRON_SECRET) and webhook/beam (HMAC) were correctly protected already.
Now one shared $lib/server/http/same-origin, preferring sec-fetch-site (browser-set, unforgeable by page script) over Origin.
This is defence in depth, not the primary CSRF control — SameSite=Lax is, and it held throughout.
🐛 Two bugs in my own new code
Found by hunting the collections work after merging it:
- D1 bind limit.
listCollections()has noLIMITand every id went into oneinArray. D1 binds at most 100 parameters per statement, so the page would break silently at 101 collections. Now chunked, verified at the 99/100/101 boundaries. The content query engine already documented this limit — I ignored the established pattern. - O(n×m) lookup.
titleFor()filtered the full localizations array per row. Indexed into aMaponce.
✅ Verified on the live demo
Browser-tested end to end before tagging: hydration, login through the real form, all 12 admin routes (no error shells, no stale version copy), collection created → slug auto-derived → live on the public API, zero console errors, and mobile at 375px with no overflow, scrollable tables and no iOS zoom.
149 tests (was 122). The origin guard and both collections fixes are mutation-verified.
🚀 Upgrading
git fetch upstream && git merge upstream/mainNo migration. No config change. Check any server-to-server client that POSTs to the cart or checkout endpoints — add an Origin header if it lacks one.
Setup instructions (/admin/settings/secrets vs the Cloudflare dashboard) are unchanged from v3.7.1.
Also closed
Fork-filed #133 (CSP blocks hydration) and #134 (TDZ crashes the admin bundle) — both already fixed in v3.8.x. Closed with the mechanism, the fix, and the ordering caveat that fixing CSP first exposes the TDZ crash, so they must be applied together.
Known gaps
Collections have no edit or delete yet — ShopService exposes only list and create. The page is honest about that rather than showing controls that don't work.
Full changelog: v3.8.1...v3.9.0