Skip to content

v3.9.0 — Collections, add-to-cart, admin i18n, origin hardening

Choose a tag to compare

@thunpisit thunpisit released this 02 Aug 03:30
· 35 commits to main since this release
4f20da5

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/cart or the checkout endpoints without an Origin header now get 403. Browsers are unaffected — they have sent Origin on every POST since ~2020.

A missing Origin used to pass the guard:

if (!origin) return null; // same-origin fetch usually omits it

A 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 no LIMIT and every id went into one inArray. 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 a Map once.

✅ 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/main

No 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