Releases: codustry/khaopad
Release list
v4.3.0 — theme/engine split complete
v4.3.0 — the theme/engine split is complete (#174)
This release finishes the work that makes one promise to every downstream repo: upgrading upstream never costs you your theme again.
Why upgrades used to lose your UI
Your custom look lived in forked engine files — the layout, the homepage, checkout. Every git merge upstream/main hit conflicts in those exact files, and every resolution either dropped your brand (upstream side) or silently dropped upstream's fixes (your side). Measured on a real fork: the homepage alone diverged 23 → 592 lines.
What replaces that, as of this release
Your theme now lives in code upstream never touches, registered through seams the engine promises to keep:
| Your customization | Where it lives now |
|---|---|
| Header / footer | setChrome({ header, footer }) — components in src/lib/deployment/ |
| Homepage | setChrome({ home }) — the route file is engine-owned; your markup is a registered component |
| Checkout fields (e.g. Thai tax invoice) | registerCheckoutSlots() — three slots, contribution flows into billingAddress end-to-end |
| Fonts, meta, verification tags | src/app.head.html — injected on every page, deployment-owned |
| Colors, radius, display font, logo | /admin/settings theme tokens — operator config, zero code |
| Everything else visual | README.md, wrangler.toml, src/lib/deployment/** are fork-side by contract |
All registrations go in src/lib/plugins/registrations.ts (loaded by server and client — registering elsewhere causes SSR-then-snap-back; see chrome.ts).
The guarantee, in writing and in CI
docs/THEME-CONTRACT.md— the full contract: every seam above, the props your components receive, Paraglide message-key stability (a shipped key is never removed within a MAJOR), which files are fork-side, and the route add/add precedence rule.THEME_CONTRACT_VERSION = 1.0.0(src/lib/theme-contract.ts) — removals of any contract surface are a MAJOR bump; additions are MINOR.pnpm run guard:contractruns in CI on every commit: if an engine change deletes anything a theme may depend on (a slot, a props field, a message key, a building-block component), the build fails with the item named until the MAJOR is bumped explicitly. A contract break can no longer ship as a quiet refactor.
How to upgrade a themed fork to this release
- Merge:
git fetch upstream && git merge upstream/main. Expect conflicts ONLY in files you own (README.md,wrangler.toml, and — one last time — any engine file you forked for looks). Keep your side for the first two. - For each engine file you forked visually (homepage, header, footer): move your markup into
src/lib/deployment/YourComponent.svelte, take upstream's version of the engine file, and register yours:then// src/lib/deployment/chrome.ts import { setChrome } from "$lib/components/www/chrome"; import YourHome from "./YourHome.svelte"; setChrome({ home: YourHome });
import "$lib/deployment/chrome";fromsrc/lib/plugins/registrations.ts. - Move colors/radius/fonts you had hard-coded in CSS into
/admin/settingstheme tokens where possible. - Run the gate:
pnpm test && pnpm run guard:contract && pnpm build. - Done — your next upstream merge should be conflict-free. The reference fork (
codustry/khaopad-example) did exactly this migration in this release cycle, and its Step-7 sync was the first fully mechanical, zero-conflict merge in the project's history.
Worked example: khaopad-example@cc1e16b is the complete homepage migration, commit-sized.
Also in this release
- Step 5 (#187) — theme tokens as operator config: background/foreground/accent colors, radius, display font, all strictly validated server-side and at render (nothing style-breaking can reach the style attribute), SSR-first so no flash of default theme.
- Step 6 (#188) — homepage behind the seam, SSR-verified byte-identical for unthemed installs.
- Step 7 (#189) — the contract: docs, version constant, CI guard (floor semantics, negative-tested).
- From the v4.2.x line already on main: tax-entity fields persist end-to-end on billing addresses (#171/#185), credential endpoints rate-limited (#182/#184/#186),
/admin/profilefor self-service password change (#178).
1007 tests; CSS-inventory, head, and contract guards all green; deployed and verified on the reference deployment.
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
v3.8.1 — Two production 404s (site root, consent banner) + link crawler
Two live 404s that a browser never shows you, found by driving the deployed site instead of reading source.
Includes everything in v3.8.0 — cookie prefix fix (#120) and mobile UX. Deploying still logs every admin out once; announce before rolling out.
🔴 The site root 404'd for Accept-Language: *
GET / returned 404 to any client sending the wildcard — including Node's fetch, which sends it by default. curl worked, so the site looked healthy from a terminal.
curl -sI https://…/ → Location: /en ✅
node -e 'fetch("https://…/")' → 404 at /* ❌
The guard was inert:
if (lang && SUPPORTED_LOCALES.includes(toLocale(lang)))
throw redirect(308, `/${lang}`); // ← raw tag, not the validated onetoLocale() coerces anything unknown to DEFAULT_LOCALE, so includes(toLocale(x)) is true for every input — then the redirect used the raw tag:
Accept-Language |
Guard | Redirects to | |
|---|---|---|---|
en-US |
true | /en |
✅ |
* |
true | /* |
❌ 404 |
zz |
true | /zz |
❌ 404 |
* is legal per RFC 9110 §12.5.4, so this was real traffic — uptime monitors, crawlers, and any client not sending a specific language got a 404 homepage. If you run synthetic monitoring against /, check whether it has been silently failing.
🔴 The consent banner linked to a 404
The privacy-policy href was hardcoded, so any install without a page at that slug shipped a broken link on its GDPR consent banner — the one link there that legally ought to resolve.
The layout now looks the page up and passes an href only when a published page exists; the banner omits the link otherwise. Looked up rather than configured, so it self-corrects when you publish or unpublish — no redeploy. A draft policy deliberately doesn't count.
Check your own install: if you have no published page at /privacy-policy, that link has been 404ing.
🔧 Link crawler
node scripts/smoke-links.mjs https://your-site.exampleCrawls a deployed install and exits non-zero on any broken internal link. It found the first defect and independently reproduced the second in both locales across 17 pages.
Unit tests don't follow hrefs — only a crawler finds "this link points at nothing". Worth wiring into your deploy pipeline.
📐 163-character lines
At 1920px the banner copy ran the full viewport width — measured 163ch against the 45–75ch typographic guideline. Past ~90ch the eye loses its place returning to the next line: the worst property to give text asking for a privacy decision.
🚀 Upgrading
git fetch upstream && git merge upstream/mainNo migration, no config change. Announce the forced logout (from v3.8.0's cookie change) and deploy.
Setup instructions — what belongs at /admin/settings/secrets vs the Cloudflare dashboard — are unchanged from v3.7.1.
Tests
121 (was 109). Locale and consent-banner guards are mutation-verified; the locale test keeps the original defective predicate alongside the fix so the failure mode stays documented.
Full changelog: v3.8.0...v3.8.1
v3.8.0 — Cookie prefix fix (#120) + mobile UX
⚠️ Deploying this logs every admin out, onceThe session cookie name changes, so all existing sessions become invalid. Announce before you deploy. Nothing else is affected — no data migration, no re-configuration.
🍪 Cookie prefix — closes the practical half of #120
The session cookie shipped as __Secure-__Host-khaopad_session. Better Auth composes the name as an unconditional concatenation (cookies/index.mjs):
name: `${secureCookiePrefix}${name}` // "__Secure-" in productionIt never checks whether the configured name already carries a prefix, so "__Host-khaopad_session" got doubled. Per RFC 6265bis §4.1.3.2 a prefix only carries its guarantees as the leading prefix — so __Host- was inert. The config bought no subdomain protection while looking like it did.
Verified against the deployed demo, with controls:
| Cookie | Domain set? |
Accepted? | Means |
|---|---|---|---|
__Secure-__Host-probe |
✅ | true | __Host- not enforced |
__Host-probe_control |
✅ | false | Correct name → refused. Probe valid. |
__Host-probe_valid |
✗ | true | Sanity check |
curl alone looks healthy here — it doesn't enforce prefix rules. The browser check is what settles it.
Fix: name is now plain khaopad_session; Better Auth emits __Secure-khaopad_session, which browsers do enforce. The __Host--equivalent attributes (path=/, no Domain, httpOnly, SameSite=Lax) are pinned explicitly so a future default change can't relax them.
Real __Host- enforcement needs an upstream Better Auth change; #120 stays open for that. If you have no sibling subdomains on your registrable domain, this changes nothing for you in practice.
📱 Mobile UX
Audited the deployed admin at 375×812 by measuring rendered geometry, not reading markup.
Tables were clipped, not scrollable
Every admin list view truncated on a phone. Wrappers used overflow-hidden, which clips — a 457px table in a 375px viewport lost its right-hand columns with no way to reach them. "Updated" was cut mid-word; the Actions column was unreachable.
13 tables fixed — articles, pages, categories, tags, forms, subscribers, api-keys, content, content/[collection], shop products/orders/discounts, and the product variants table (which had no wrapper at all). overflow-x-auto keeps the rounded-corner clipping the original was there for.
iOS zoomed on every input
Inputs at 14px sit below the 16px threshold where iOS Safari zooms on focus and stays zoomed. Now text-base on mobile, sm:text-sm on desktop.
Tap targets below the 44px minimum
Inputs and buttons were 36px against Apple/Google guidance; icon buttons at 36×36 were worst. Now h-11 on touch, sm:h-9 to preserve desktop density.
Both live in the shared ui/input and ui/button components, so every form in the app benefits.
Consistent permission errors
Eleven routes silently redirected to /admin on insufficient role, while users/audit/settings threw an explanatory 403. Same situation, two behaviours — and a silent bounce leaves the user with no idea why they moved. All now 403 with a reason.
✅ What the audit found clean
Worth recording, so nobody re-investigates: no horizontal page overflow, no console errors, all inputs labelled, alt text present, no unsafe target=_blank, sidebar collapses correctly to a hamburger, and the public site has canonical + hreflang + JSON-LD with a single H1.
The mobile shell was already solid. Every defect was in the shared primitives or the table wrappers — which is why one fix each covered the whole app.
📋 Known gap, not fixed here
No site-wide og:image fallback. Seo.svelte emits og:image only when a page supplies one, so any page without a hero image shares to social with a blank card. Fixing it needs a default asset, which is a design decision rather than a code one.
🚀 Upgrading a fork
git fetch upstream && git merge upstream/mainNo migration. No config change. Announce the forced logout, then deploy.
Setup instructions (what belongs at /admin/settings/secrets vs the Cloudflare dashboard) are unchanged from v3.7.1 — that table is still accurate.
Tests
109 (was 103). The cookie-name guard is mutation-verified: restoring __Host- fails 2 tests.
Full changelog: v3.7.1...v3.8.0
v3.7.1 — Admin credentials portal (+ critical Beam auth fix)
Manage BeamCheckout and Resend credentials from /admin/settings/secrets instead of wrangler secret put + Cloudflare account access. Encrypted at rest with AES-GCM-256.
⚠️ If you are on v3.7.0, upgrade. That release shipped a Beam adapter that could not authenticate — see Critical fix below. v3.7.0 is marked superseded.
⚙️ Setup: what goes where
This is the section forks need. Two lists.
✅ Manage at /admin/settings/secrets
Set these from the admin UI. super_admin only.
| Key | Required? | What it does |
|---|---|---|
BEAM_MERCHANT_ID |
Yes, for Beam | HTTP Basic username. Beam authenticates as base64(merchantId:apiKey) — a separate credential from the API key. Shown in full (a public identifier, not a secret) so you can verify it against Lighthouse. |
BEAM_API_KEY |
Yes, for Beam | Basic password. Creates charges, issues refunds. |
BEAM_WEBHOOK_SECRET |
Yes, for Beam | Base64 HMAC key verifying X-Beam-Signature. Wrong value = customers charged, orders stuck pending. |
RESEND_API_KEY |
Optional | Order receipts + abandoned-cart email. Unset = email silently disabled, checkout still works. |
All three Beam values are required together — the provider refuses to construct with any missing, so misconfiguration fails at boot rather than at checkout.
Secret fields are write-only: after saving, only a masked ••••••••4a2f preview is shown. Plaintext is never sent back to the browser.
🔒 Still required in the Cloudflare Dashboard
| Key | Why it can't move |
|---|---|
BETTER_AUTH_SECRET |
Read in authHook on every request, before a session exists — storing it behind a session-gated page is circular. It signs session cookies, so read access = forge a login as any user. And it is the key-derivation root encrypting the secrets table, so storing it beside the ciphertext defeats the encryption. |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID |
Deploy-time credentials that create the Worker — they cannot live inside it. |
Also Cloudflare-only (bindings and plain config)
Bindings in wrangler.toml: DB (D1), MEDIA_BUCKET (R2), CONTENT_CACHE (KV).
Vars: BETTER_AUTH_URL, PUBLIC_SITE_URL, CMS_SITE_URL, DEFAULT_LOCALE, SUPPORTED_LOCALES, CRON_SECRET, RESEND_FROM, BEAM_BASE_URL (optional — set to https://playground.api.beamcheckout.com for sandbox).
ℹ️
RESEND_FROMstays env-only, so a site can have the API key set in the UI and still not send. Tracked in #116.
🔁 Precedence: env always wins
A key set as a Cloudflare env var takes precedence; the UI shows "Set in Cloudflare" with the field disabled. Deliberate:
- a leaked key can be rotated with
wrangler secret putwithout needing a working admin panel — which may be exactly what's compromised - existing deployments keep working untouched; nothing to migrate
- staging can override production values from a shared database
To manage a key from the UI, remove the env var first.
🚨 Critical fix: Beam authentication
The adapter assumed Beam was "roughly Stripe-shaped". It is not, and four defects meant it could never authenticate against the real API:
| # | We did | Beam requires | Impact |
|---|---|---|---|
| 1 | Authorization: Bearer <apiKey> |
HTTP Basic base64(merchantId:apiKey) |
Every charge and refund rejected |
| 2 | No merchant ID at all | Merchant ID is the Basic username | Cannot authenticate |
| 3 | /v1 in base URL → /v1/charges |
/api/v1/charges on the bare host |
404 |
| 4 | Hex digest, lowercased signature | base64, case-sensitive, key decoded from base64 first | Real webhooks rejected — customers charged, orders stuck pending |
Defect 4 is the worst: the customer pays, Beam confirms, we reject the signature, and the order sits pending forever while the money is gone.
Added 13 contract tests pinning the wire format against Beam's docs — nothing previously asserted headers, paths, or digest encoding, which is why all four were invisible. Mutation-verified: restoring bearer auth fails 1 test; restoring the hex digest fails 4.
Verify against a real sandbox webhook before trusting production traffic. These tests pin the format against documentation, not a live endpoint, and Beam's webhook doc page 404'd on direct fetch during research (the scheme was corroborated across two independent sources).
🚀 Upgrading a fork
git fetch upstream && git merge upstream/main
npx wrangler d1 migrations apply <your-db> --remote # applies 0022_managed_secretsThe migration is required. Without it the page renders and explains itself, but saving fails.
Expect conflicts in wrangler.toml (keep yours — upstream ships placeholders), and possibly .github/workflows/deploy.yml and README.md if customised. Engine files under src/ should take upstream.
BETTER_AUTH_SECRET makes every stored secret undecryptable. Intentional — it fails closed rather than handing a wrong key to a payment provider. The UI reports "Cannot decrypt" and prompts re-entry. A re-key command is tracked in #116.
What else is in v3.7
First tagged release; package.json moves 0.1.0 → 3.7.1 to match the milestone scheme the README already used (shipped through v3.5; v3.6 = registry + spec layer).
- Registry + spec layer (#91 #94 #96 #100) — user-definable content types; typed spec/attribute model with value intervals, qualifiers and polarity; edge attributes and external relation targets
- Phase 5 (#105) — idempotent demo seed scripts, integration tests applying the real migration files
- CI fixes (#102 #103 #106) — nav registry no longer depends on module init order (this took the demo down once), vitest baseline, Paraglide compile in the deploy job, D1 step gated on token scope
Other fixes
- Client-bundle leak (caught pre-merge). Resolving credentials in the shop plugin's
onInitput the secrets service on the browser import graph. SvelteKit's guard blocked the build. Moved tobeam-config.server.ts; verified no crypto reaches the client bundle. Also fixed latent staleness — a provider built at boot would keep using a key you'd since rotated. - D1 upsert switched to raw
ON CONFLICT DO UPDATE; drizzle's builder isn't uniform across versions on D1's sqlite dialect. - macOS duplicate-file guard in
.gitignore. A duplicated migration silently re-runsCREATE TABLE, blocks the chain, and leaves schema tests passing against stale schema.
Tests
103 total, up from 46 at the start of this cycle. Guards for masking, unmanaged keys, and the Beam wire format are all mutation-verified.
Full changelog: f1aef87...v3.7.1
v3.7.0 — Admin credentials portal
⛔ Superseded by v3.7.1 — do not use
This release shipped with a BeamCheckout adapter that could not authenticate against Beam's real API. It also incorrectly stated that
BEAM_MERCHANT_IDwas not needed. Both are fixed in v3.7.1.If you took v3.7.0, upgrade. See v3.7.1 for the corrected setup instructions.
Original notes below, retained for the record. The setup table in this release is wrong — BEAM_MERCHANT_ID is required, and Beam uses HTTP Basic auth, not bearer tokens.
Admin credentials portal at /admin/settings/secrets, plus the v3.6 registry + spec layer and Phase 5 test work. See v3.7.1 for accurate documentation.