# Changelog

One entry per released tag, grouped by minor. Latest first.

Pre-1.0 the surface is intentionally evolving — every release may
change something operators depend on. Read each entry before
upgrading across more than a few patches at a time.

## v0.2.x

- v0.2.26 (2026-05-29) — **Update the vendored blamejs runtime to v0.13.44.** The bundled blamejs runtime moves from v0.13.0 to v0.13.44, carrying a run of upstream security and reliability fixes through the crypto, storage, and middleware layers the shop is built on, with no API or configuration changes. Highlights: DNSSEC validation now bounds the work spent on colliding keys and signatures (KeyTrap / NSEC3 resource-exhaustion defense); a sealed database column that fails to unseal now returns null instead of risking forged or cross-row ciphertext; the encrypted database gains a temporary-storage free-space guard and a shutdown watchdog that preserves the final flush on exit; S/MIME chain verification binds the leaf certificate to the verifying signer key; archive extraction refuses Windows reserved and alternate-data-stream path names; and the replay-nonce store is memory-capped and fails closed under flood. **Changed:** *Database reliability under low temporary storage* — The encrypted database now refuses growth-writes with a clear error when its temporary storage drops below a safety headroom (rather than risking corruption), and a shutdown watchdog preserves the final database flush if a shutdown phase hangs. Both harden the container restart path. **Security:** *Bundled blamejs runtime updated to v0.13.44* — Pulls in the upstream security fixes accumulated since v0.13.0: DNSSEC KeyTrap / NSEC3 work caps, sealed-column null-on-unseal-failure (no forged or cross-row ciphertext), S/MIME leaf-to-signer binding, archive-extraction rejection of Windows reserved / alternate-data-stream names, static-file sibling-prefix path containment, and a memory-capped, fail-closed replay-nonce store. No API or configuration changes are required.

- v0.2.25 (2026-05-29) — **Harden session and admin cookies with __Host- / __Secure- name prefixes.** The session, login, and admin cookies now carry the browser-enforced `__Host-` / `__Secure-` name prefixes when served over HTTPS. These prefixes bind a cookie to a secure, same-origin context — a browser only accepts a `__Host-` cookie that was set with `Secure`, `Path=/`, and no `Domain` attribute — which closes off cookie-injection and session-fixation paths from a related origin or a non-secure context. The secure-versus-plain decision follows the forwarded request protocol, so a deployment behind a TLS-terminating proxy (the production setup) gets the hardened prefixes while local development and tests over HTTP keep the bare names and keep working. Returning visitors are not signed out by the upgrade: the cookie reader resolves both the prefixed and the legacy name during the transition. **Security:** *`__Host-` on the session and login cookies* — Over HTTPS the storefront session and login cookies are issued as `__Host-shop_sid` and `__Host-shop_auth` with `Secure`, `Path=/`, and no `Domain` — the attribute set a browser requires before it will store a `__Host-` cookie, so the session cookie can no longer be planted or overwritten by a related origin or a non-secure context. · *`__Secure-` on the admin cookie* — The admin session cookie is scoped to `Path=/admin`, so it takes the `__Secure-` prefix (which requires `Secure`) rather than `__Host-` (which mandates `Path=/`). The edge cache-skip check and the audience bucketing were updated to recognize the prefixed names so a signed-in response is never served from the shared edge cache.

- v0.2.24 (2026-05-29) — **Scope cart-line edits to your own cart, harden structured data, and redact auth errors.** A set of request-path hardening fixes. Cart line edit and remove are now scoped to your own session cart, so a cart-line id belonging to another shopper can no longer be used to change quantities on or delete their item — a mismatched id is a no-op, not a mutation. The product structured-data (JSON-LD) embedded in page heads now neutralizes every `</script` close-tag variant (with trailing space, slash, or newline), not just the exact `</script>`, closing a markup-injection path from admin-entered product fields. Account sign-in, registration, and passkey error responses now return a generic message plus a request id instead of the raw internal error string, with the full error logged server-side for the operator. And the worker DB/R2 bridge secret is checked at boot: a secret shorter than 32 characters logs a loud warning so a weak secret is visible in the boot log (the documented generator produces 43 characters, so a by-the-docs deploy stays quiet). **Security:** *Cart-line edits are scoped to your cart* — `POST /cart/lines/:id/update` and `/remove` now resolve the requester's session cart and scope the change to it (`WHERE id = ? AND cart_id = ?`), so a cart-line id from another shopper's cart can't be used to alter or delete their line — it resolves to a no-op rather than a cross-cart mutation. Legitimate edits to your own cart are unchanged. · *Structured-data close-tag escaping hardened* — JSON-LD embedded in page heads (edge and origin, byte-identical) now escapes every `</script` sequence the HTML parser treats as a tag-closer — including `</script `, `</script/`, and `</script` followed by a newline — not only the exact `</script>`. This removes a markup-injection vector from admin-entered product fields that flow into structured data. · *Account auth errors no longer leak internals* — The sign-in, registration, and passkey ceremony endpoints now return a generic message and a request id on an unexpected server error instead of the raw error string; the real error is logged server-side, correlated by that request id. Client-shape (400) and other expected responses are unchanged. · *Bridge-secret entropy warning at boot* — The worker DB/R2 bridge secret (`D1_BRIDGE_SECRET`) authorizes the database and object-storage bridge, so a weak value is high-impact. The container now logs a loud boot warning in production when it is shorter than 32 characters. The documented generator produces 43 characters; if you see the warning, regenerate the secret with the command it prints.

- v0.2.23 (2026-05-29) — **Rate limiting and cross-site request isolation on the request lifecycle.** Two request-lifecycle defenses are now composed into every storefront and admin request. A per-client-IP rate limit backs the whole site (a generous global budget that a normal browsing and checkout session never trips) with tighter budgets on the abusable POST/auth endpoints — sign-in, passkey enrolment, checkout, gift-card balance lookup, account registration, newsletter, review/question submit, and survey response — so credential and passkey spraying, gift-card brute-forcing, and checkout flooding are shut down. Separately, a fetch-metadata gate refuses cross-site state-changing requests using the browser's Sec-Fetch headers, adding CSRF defense-in-depth on top of the SameSite session cookies. The client IP is read from the edge-forwarded header (the container sits behind the Worker), so limits apply per real visitor; the payment-webhook and health-probe paths are exempt. No change for normal use. **Added:** *Per-client rate limiting* — A generous global token-bucket limit per client IP backs the whole site, with tight fixed-window budgets on the abusable POST/auth endpoints (login, passkey register/add, checkout, gift-card balance, register, newsletter, review/question, survey). Keyed on the real client IP forwarded from the edge; payment-webhook and health-probe paths are exempt. The single-instance container uses an in-memory backend. · *Cross-site request isolation (fetch-metadata)* — Cross-site state-changing requests (the CSRF vector) are refused using the browser's Sec-Fetch-Site/Mode/Dest headers — same-origin requests and direct navigations pass; payment webhooks are exempt. This restores defense-in-depth alongside the SameSite session cookies.

- v0.2.22 (2026-05-29) — **See your full order history, track shipments, reorder, and request returns.** The customer order surface is now complete end-to-end. A new order-history list shows every order (cursor-paginated), and each order page gains a status timeline, a carrier tracking panel (tracking number + the carrier's public tracking link), a one-click Reorder that rebuilds a cart from the order, and a Request-a-return button for eligible orders — the same Reorder/Return actions also appear per row on the account dashboard and the history list. On the operator side, the admin order screen gets a shipment panel to attach a carrier + tracking number and record shipment events, and the returns console's Refund now issues the payment-provider refund first (then records the RMA) whenever the order has a captured charge, so a return can't be marked refunded while the customer is left un-refunded; manual/guest orders with no charge fall back to an explicit record-only step. **Added:** *Order history + tracking + reorder + returns (customer)* — `/account/orders` lists all your orders with pagination; each order page shows a status timeline and carrier tracking, plus Reorder (`POST /orders/:id/reorder` rebuilds a cart from the order's lines at current prices) and Request-a-return for eligible orders. The dashboard and history rows carry the same actions. · *Shipment + tracking panel (admin)* — The admin order detail screen can attach a shipment (carrier + tracking number) and record shipment events; marking delivered advances the order's lifecycle. Tracking the order surfaces the carrier's public tracking link to the customer. **Changed:** *Return refunds move the money* — The returns console Refund action now issues the payment-provider refund first and records the RMA only on success when the order has a captured charge, so an approved return is never marked refunded without the customer actually being refunded. Orders with no captured charge use an explicit record-only step that points to the order.

- v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.

- v0.2.20 (2026-05-29) — **Manage a subscription yourself — pause, skip, change, or reactivate.** The subscriptions account screen previously only let a customer cancel. It now exposes the full self-service set: pause and resume, skip the next shipment, change the quantity, change the delivery frequency, and reactivate a recently-cancelled subscription within its grace window. Each action is state-gated (only the controls that apply to a subscription's current state are shown), confirm-aware where it changes billing, and reports back with a status notice. No more cancelling just to make a change. **Added:** *Subscription self-management* — `/account/subscriptions` gains Pause / Resume, Skip next shipment, Change quantity, Change frequency, and Reactivate (within the grace window) alongside Cancel. Controls are shown only when valid for the subscription's state; quantity and frequency are validated server-side; pause and cancel go through a confirmation step.

- v0.2.19 (2026-05-29) — **Search results render the same product cards as the rest of the store.** A product with no image showed an outdated, text-only card on the search-results page when that page was served from the edge, while the home and category grids (and the origin-rendered search page) used the current placeholder-illustration card. The edge search page now emits the same card markup as every other grid, so a no-image product looks identical wherever it appears. A render-level parity check now pins the search page's markup byte-for-byte across the edge and origin paths so this kind of drift is caught automatically. **Fixed:** *Consistent no-image product card on search* — The edge-rendered search results page used a stale text-only card for products without a hero image; it now renders the placeholder-illustration card used across the rest of the storefront, byte-identical to the origin render.

- v0.2.18 (2026-05-29) — **Admin: a clear "payments aren't live" warning, and void a gift card.** Two admin-console fixes. The landing page now warns that payments aren't live whenever Stripe isn't configured — independently of the setup wizard — so an operator who finishes the identity wizard isn't misled into thinking customers can check out when they still can't. And gift cards can now be voided from the console: the card detail screen gets a confirm-gated Void action for active cards, composing the existing void capability. **Added:** *Gift-card void* — The gift-card detail screen shows a confirm-gated "Void card" action for active cards. Voiding goes through a confirmation step and is content-negotiated like the rest of the console (a bearer-token client gets the voided card as JSON; already-redeemed cards are refused). **Changed:** *Payment-readiness banner on the admin landing* — The admin landing shows a "Payments aren't live yet — customers can't check out. See Integrations" banner whenever Stripe isn't enabled, gated on live payment readiness rather than on whether the setup wizard was marked complete. The existing not-set-up identity banner is unchanged; both can appear together.

- v0.2.17 (2026-05-29) — **Manage your passkeys and edit your profile from your account.** Signed-in customers can now manage their own passkeys and edit their profile, instead of only being able to enrol a passkey at registration. A new account screen lists each enrolled passkey (device transport + when it was added) with an add-another flow and a confirm-gated revoke; revocation is scoped to the signed-in account and refuses to remove your last sign-in method when no other (OAuth) method is linked, so you can't lock yourself out. A separate profile screen edits the display name. **Added:** *Passkey management* — `/account/passkeys` lists your enrolled passkeys, lets you register an additional one, and revoke one you no longer use (behind a confirmation step). Revocation only ever affects your own credentials, and the last remaining sign-in method is protected against removal. · *Profile editing* — `/account/profile` edits your display name with a confirmation notice. Account links for both screens were added to the account dashboard.

- v0.2.16 (2026-05-29) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` — pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` — create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).

- v0.2.14 (2026-05-28) — **Manage products end-to-end from the admin console — variants, prices, and images.** The admin console can now build a complete, sellable product without dropping to the JSON API. Previously creating a product only captured its name, slug, status, and description — there was no way to add a variant, set a price, or attach an image from the console, so a product made there was not sellable. Each product now has a management screen (reachable from the product list) with variant create/edit/delete, per-variant price setting plus price history, and media attach / upload / delete, with confirmation steps for deletes. The screen is content-negotiated: a bearer-token API client still gets JSON (now including the full product model), a signed-in browser gets the management UI. **Added:** *Product management screen in the admin console* — Each product's title in the admin product list now links to a detail screen where an operator can edit the product's fields, add/edit/remove variants, set a variant's price and see its price history, and attach/upload/remove product images — the full path to a sellable product, all from the console. Destructive actions (remove variant/image) go through a confirmation step. · *Catalog read helpers* — `prices.currencies(variantId)` (the distinct currencies a variant is priced in) and `media.get(id)` (single media row) were added to support the screen. **Changed:** *`GET /admin/products/:id` is content-negotiated* — It previously returned JSON only; it now returns the management screen to a signed-in browser and a fuller JSON model (product + variants + prices + media) to a bearer-token client. The existing variant/price/media write endpoints are unchanged in contract — the new browser forms route to the same handlers.

- v0.2.13 (2026-05-28) — **Product-grid and product-page visual polish.** Small visual refinements to the catalog grid and product page. Product tiles previously all carried the same fixed corner glow, so a grid of them read as one card stamped repeatedly; the glow position now rotates per tile so the grid looks like a designed set. The product page's buy box gets clearer elevation so it reads as a distinct purchase surface, and the fallback placeholder shown for a product with no image is now legible on the dark theme instead of a near-invisible mark. **Changed:** *Product tiles no longer share one identical glow* — Each product card's media glow now rotates through a set of positions per tile (matching the collection cards) instead of every tile glowing in the exact same corner, so a full grid reads as a designed set rather than a single card repeated. · *Clearer product-page buy box* — The product page's buy box gains a stronger border and a soft shadow so it stands out from the page as the purchase surface. · *Legible image-less placeholder* — The fallback artwork shown when a product has no image was nearly invisible on the dark canvas; its contrast is lifted (grid lines, shield detail, and caption) so it reads as an intentional placeholder. Applied identically on the edge and container render paths.

- v0.2.12 (2026-05-28) — **Truthful product availability and real SEO metadata — canonical URLs, structured data, and per-page descriptions.** Two related improvements to the storefront's honesty and discoverability. Product pages now report real availability from inventory (instead of a hardcoded 'In stock') and reflect it in their structured data, and the shipping line is suppressed for digital products. Separately, every page now emits a canonical URL and a populated Open Graph URL (previously empty — which broke social-share unfurls and let sort/currency query parameters fork into duplicate indexed pages), the home page emits the same Organization/Website structured data the edge already did, breadcrumb structured data uses absolute URLs, and the home, category, and search pages get their own meta descriptions instead of one shared default. The footer's account links are regrouped and a shipping-and-returns link was added. **Changed:** *Product availability is truthful* — The product page's stock badge and its `Product`/`AggregateOffer` structured-data `availability` are now driven by real per-SKU inventory (`InStock` / `OutOfStock`) rather than a hardcoded value, so a sold-out item reads as sold out everywhere and structured data no longer mismatches reality. The shipping-time line is suppressed for all-digital products (shown as delivered on purchase). Identical across the edge and container render paths. · *Footer account links regrouped* — The footer's operator-oriented column is now a customer 'Your account' grouping (Account / Orders / Contact), the public Admin link was removed, and a 'Shipping & returns' link was added to the policy area. · *Form fields are legible on the dark theme* — Text inputs, selects, and textareas (checkout, address book, search, sign-in) previously rendered with a 10%-opacity hairline that disappeared against the near-black canvas — fields read as empty voids. They now have a clearly-bounded outline and a lifted fill, stretch to fill their column, and the rule covers `tel`/`url`/`password` inputs it previously missed. **Fixed:** *Canonical + Open Graph URLs on every page* — Every page now includes `<link rel="canonical">` and a populated `og:url` (both were absent/empty before). This fixes social-share previews that unfurled with no link target and prevents tracking/sort/currency query-string variants from being indexed as duplicate pages. Emitted on both render paths, derived from the request's own origin. · *Consistent structured data + unique page descriptions* — The home page now emits the `Organization` and `WebSite` (search-action) structured data that previously only appeared on the edge render, breadcrumb structured-data item URLs are absolute (so the breadcrumb rich result is eligible), and the home, category, and search pages each carry their own meta/OG description instead of sharing one generic string.

- v0.2.11 (2026-05-27) — **A clearer checkout — itemised order summary, honest pricing, and recoverable errors.** Reworks the checkout and cart experience. Checkout now uses the same two-column layout as the cart, with a sticky order-summary rail that lists the actual items being bought (thumbnail, title, quantity, line total) rather than a bare number in a lonely column. The summary no longer shows a misleading 'Total' equal to the subtotal — it shows the subtotal plus an honest note that tax and shipping are calculated at the payment step, matching the cart. Adding an item now confirms it and offers to keep shopping, checkout and cart failures render as proper pages you can recover from instead of a blank error string, required fields announce themselves to screen readers, and the header 'Framework' link works from every page. **Changed:** *Checkout shows an itemised, sticky order summary* — The checkout page adopts the cart's two-column layout: the shipping form on the left and a sticky order summary on the right that lists each item (thumbnail, title, quantity, line total) with an 'Edit cart' link back to the cart. Previously the form was a narrow column with a bare subtotal box and no way back. · *Honest checkout pricing* — The order summary no longer renders a 'Total (plus tax + shipping)' line bound to the subtotal value (which read as the final price but wasn't). It now shows the subtotal and the same honest note the cart uses — tax and shipping are calculated at the payment step. · *Add-to-cart confirmation + continue shopping* — Adding an item lands on the cart with a dismissible 'Added to cart' confirmation, and the cart now has a 'Continue shopping' link so multi-item shoppers aren't dead-ended. **Fixed:** *Checkout and cart errors are recoverable, not blank pages* — A declined gift card, an invalid input, or a checkout failure previously dumped an unstyled plain-text error. These now render as styled pages with the reason and a way back — gift-card and loyalty errors re-display the checkout form with the message inline so the shopper can correct it without losing their place. · *Required fields announce to screen readers* — Required form fields kept only a red asterisk (color-only); they now include a visually-hidden '(required)' so assistive technology conveys it. · *Header 'Framework' link works on every page* — It pointed at an on-page anchor that only existed on the home page (a no-op elsewhere); it now resolves from any page.

- v0.2.10 (2026-05-27) — **Accessibility and mobile polish — the cookie bar stops covering the page, and forms behave on phones.** A round of default-theme refinements focused on the cookie consent bar, mobile ergonomics, and accessibility. The consent bar previously floated over page content — on a product page or checkout it could sit on top of the price, variant picker, and the add-to-cart / continue-to-payment button on a first visit. It is now a compact bottom bar that reserves its own space (so nothing important is ever hidden under it), scrolls within itself if the text is long, and receives keyboard focus when it appears. Alongside it: mobile form inputs no longer trigger iOS zoom-on-focus, the header touch targets meet the 44px minimum, inline text links are underlined, and a consistency pass tightened button shapes, table-label typography, and small-screen layout. **Changed:** *Visual consistency pass* — Action buttons share a single corner radius (primary and secondary no longer differ in shape), uppercase micro-labels use the monospace face consistently across tables and forms, the catalog grid shows two columns on small phones instead of one, the hero stat row breathes on narrow screens, and a few dark-theme borders/placeholders that read muddy were moved onto the semantic token palette. **Fixed:** *Cookie consent bar no longer covers page content* — On a first visit the consent bar could overlap the product buy box and the checkout form (the price, variant selector, and primary button). It is now a compact bottom-docked bar that reserves matching space at the foot of the page until a choice is made, caps its height and scrolls internally for long text, and moves keyboard focus to its first control when shown — so the page's primary action is never hidden and the bar is reachable without tabbing the whole page. · *Mobile form inputs no longer zoom on iOS* — Text inputs, selects, and textareas render at a 16px minimum on touch devices, so mobile Safari no longer zooms the viewport when a field is focused (which previously left the page zoomed-in and pannable mid-checkout). · *Header touch targets meet the 44px minimum on mobile* — The account and cart controls in the header — the two primary controls on a phone — are now at least 44×44px. · *Clearer link and prompt-glyph affordances* — Inline text links in body copy are underlined (no longer distinguished by color alone), and the terminal prompt glyph on the dark accent bands renders in full contrast.

- v0.2.9 (2026-05-27) — **Storefront read pages stay fast for every visitor — the cart badge updates client-side.** Restores full edge-render speed for returning and signed-in shoppers. Every storefront read page (home, product listing, search, blog) now renders at the edge for all visitors again; the nav cart badge renders server-side and a tiny client island corrects the number from a new lightweight endpoint. The result: the fast cached page AND an accurate cart count, without sending a shopper-with-a-cart through the slower origin on every page. **Added:** *`GET /cart/count` + a cart-count island* — A new endpoint returns the current session's cart line count as JSON (`{"count": N}`), cached `no-store`, with no product hydration. A small deferred same-origin script (`assets/js/cart-count.js`) reads it on load and updates the nav badge text and its aria-label. A visitor with JavaScript off keeps the server-rendered badge. The script is a same-origin external file, so the existing `script-src 'self'` policy already permits it — no CSP change required. **Fixed:** *Fast read pages for shoppers who have a session* — A visitor carrying a cart or sign-in cookie is once again served the edge-rendered storefront pages instead of being routed to the origin container, so the home page and other read pages load quickly for returning visitors, not just first-time guests. The nav cart count stays correct via the island below.

- v0.2.8 (2026-05-27) — **A complete shipping address at checkout, and an accurate cart count on every page.** Two storefront fixes. Checkout now collects a real, shippable address — a street line, an optional apt/suite line, and a city — alongside the region, postal code, and country it already took; a signed-in customer's default saved address pre-fills the form, the address shows on the order confirmation, and the order export carries it so fulfilment has everything it needs. Separately, the navigation cart badge now shows the correct item count on the catalog, product, search, and blog pages — not only on the cart and account pages — by rendering those pages for a shopper-with-a-cart on the path that can read their session. **Changed:** *Checkout collects a complete shipping address* — The shipping step now takes a street address (line 1, plus an optional apartment / suite / unit line) and a city in addition to the region, postal code, and country it already collected, so a placed order carries an address you can actually ship to. Street line and city are marked required for the common physical-goods path; the server treats them as optional so a digital-only order still completes. A signed-in customer's default saved address pre-fills the form. · *Shipping address on the confirmation page and in the order export* — The order confirmation now shows the ship-to address, and the order CSV / NDJSON export gains `shipping_line1`, `shipping_line2`, and `shipping_city` columns (inserted after `customer_name`) so a fulfilment handoff has the full address. Consumers that key on the header name are unaffected; a consumer that keys on absolute column position should re-map to the 27-column layout. **Fixed:** *Cart count is correct on every page* — The nav cart badge previously read zero on edge-rendered pages (home, product, search, blog) for a shopper who had items in their cart, while the cart and account pages showed the real count. A request that carries a session cookie is now rendered on the path that can unseal the session and read the cart, so the badge is consistent everywhere. Guests — who have no cart — still get the fast cached render with a count of zero, which is correct for them.

- v0.2.7 (2026-05-27) — **Default-theme brand polish — broader favicon support and a larger logo.** Small refinements to the bundled default theme's branding. Every page head now advertises a PNG favicon and an Apple touch icon alongside the existing SVG favicon, so the site icon renders correctly on Safari / iOS home screens and on browsers that don't support SVG favicons, plus a theme-color so the mobile browser chrome matches the dark canvas. The header and footer logo are rendered larger for more presence. These affect the default theme only; a custom theme passed via the theme primitive is unchanged. **Changed:** *Fuller favicon + icon metadata* — The page head now includes a PNG favicon and an `apple-touch-icon` (Apple home-screen icons require PNG — SVG favicons aren't used there) in addition to the SVG favicon, plus a `theme-color` meta for the mobile browser UI. Emitted on every page across both the edge and container render paths. · *Larger header and footer logo* — The default theme renders the brand logo at a larger size in the header and footer for clearer presence.

- v0.2.6 (2026-05-27) — **Business-hours page, a graceful unconfigured-checkout state, and the brand shield on the product page.** Three storefront changes. Operators can publish their open hours: define schedules (timezone + per-weekday open/close) from a new Hours screen in the admin console, and a public /hours page renders each as a weekly grid with a live open/closed status computed per request in the schedule's timezone. The cart now degrades gracefully when no payment provider is configured — instead of a checkout button that leads to a dead route, it shows a clear, disabled 'checkout isn't set up yet' notice, and a direct visit to /checkout returns a tidy 'unavailable' page rather than a 404. And the product page's post-quantum-checkout trust badge now uses the real brand shield mark (the site favicon) in place of a CSS approximation. **Added:** *Public /hours page* — A server-rendered page listing every active schedule as a weekday grid (closed days marked), with a live open/closed pill and the next transition ("Opens Monday at 09:00" / "Closes at 17:00"). Timezone-aware and computed per request, so it isn't cached stale. · *Admin console: Hours* — A new Hours screen (and matching JSON API) to create a schedule with a timezone and per-weekday open/close times (a blank day is closed), list schedules, and archive ones no longer in use. Holiday closures and one-off date exceptions — which override the weekly base — are available through the API. The screen appears only when the business-hours primitive is wired. **Changed:** *Cart and /checkout degrade gracefully without a payment provider* — When checkout isn't configured (no payment provider), the cart no longer shows a checkout button that leads nowhere — it shows a disabled control with a clear note that online checkout isn't set up yet and the cart is saved. A direct visit to /checkout now returns a tidy 503 'checkout unavailable' page instead of a 404. Configuring a payment provider restores the full cart → shipping → payment → confirmation flow with no other change. · *Product-page trust badge uses the brand shield* — The post-quantum-secured-checkout badge on the product page now renders the actual brand shield mark (the site icon) rather than a CSS-drawn hex approximation, so the badge matches the brand identity used elsewhere.

- v0.2.5 (2026-05-27) — **Customer surveys — token-gated NPS / CSAT / CES feedback with an admin rollup.** Operators can now collect structured post-experience feedback. Define a survey (NPS, CSAT, CES, or a custom open-ended prompt) from a new Surveys screen in the admin console, then issue a single-use invitation for a customer — the console shows the survey link once for you to send. The customer opens the link and answers without signing in; the token itself is the access. Responses roll up on the survey's detail page with the kind-appropriate score (NPS index, CSAT positive-rate, CES mean) plus a per-question breakdown. Surveys can be archived when a campaign ends; existing invitation links keep working. **Added:** *Token-gated survey page* — A `/survey/:token` page renders the survey's questions — a rating scale, single-select options, or a free-text box — and accepts one response per invitation. The single-use token is the access (no login), and the page degrades cleanly to a clear notice when a link is already used, expired, closed, or invalid. The required-answer check re-renders the form with a message rather than losing the visitor's work. · *Admin console: Surveys* — A new Surveys screen (and matching JSON API) to create a survey for the chosen kind (the console seeds the standard question set; fully custom question lists go through the API), issue a single-use invitation for a customer id (the link is revealed exactly once), read the response rollup with the kind's headline score and per-question results, and archive finished surveys. The screen appears only when the surveys primitive is wired.

- v0.2.4 (2026-05-27) — **Announcement bar — publish a sitewide promo or notice strip from the admin console.** Operators can now publish a sitewide announcement bar — a strip at the very top of every storefront page for a sale, a shipping notice, or an urgent alert. Manage it from a new Announcements screen in the admin console: set the message, an optional call-to-action link, a theme (urgency, promo, info, or success), the audience (everyone, signed-out visitors, or signed-in customers), an optional start/expiry schedule, and whether visitors can dismiss it. The highest-priority active announcement shows automatically (urgency outranks promo outranks info outranks success). The bar renders on both the edge and container paths so it appears on every page including the cached ones, and a dismissed bar stays hidden across navigation. Until an announcement is published, nothing changes. **Added:** *Sitewide announcement bar* — A page-top strip rendered on every storefront page (home, search, product, cart, blog, and the storefront chrome) in one of four themes. The single highest-priority active announcement for the viewer is shown, chosen by theme rank (urgency > promo > info > success). Audience targeting covers everyone, signed-out visitors, and signed-in customers, with an optional scheduled start and expiry. An optional call-to-action link is included; its URL is restricted to https:// or a site-relative path. · *Admin console: Announcements* — A new Announcements screen (and matching JSON API) to create announcements, filter the list to those active right now, and archive ones that are done. The screen appears in the console only when the announcement primitive is wired. · *Dismiss without a round-trip* — Dismissible announcements carry a close control. With JavaScript it's hidden in place and remembered in a local cookie so it doesn't reappear as you browse; with JavaScript off, a form submission records the dismissal and returns you to the page. The bar is always server-rendered identically for everyone, so a dismissed bar never leaks back in from a cached page — it's hidden per-visitor on the client.

- v0.2.3 (2026-05-27) — **Admin console adopts the dark violet brand — the operator dashboard now matches the storefront.** The admin console has been restyled from its previous light theme and orange accent onto the same dark violet identity as the storefront: a neutral near-black canvas with violet reserved for accents, the self-hosted Hanken Grotesk and Space Mono typefaces in place of Inter, and Space Mono carrying the terminal-style chrome (header mark, navigation, micro-labels, figures, SKUs, order ids, and code). Stat cards, tables, status pills, forms, buttons, banners, nav cards, and the sign-in card are all recoloured for the dark surface with contrast preserved — order statuses keep their meaning (green for paid/shipped, amber for pending, violet for refunded, muted for cancelled), form inputs read clearly on the dark wells, and the revenue sparkline is drawn in the brand violet. Operators on the default theme get the new console on upgrade; no admin functionality, routes, or data change. **Changed:** *Dark violet admin theme* — The admin stylesheet is rebuilt on the storefront's palette — near-black surfaces, neutral hairline borders, and violet reserved for accents (active nav, primary buttons, the `/ admin` mark, focus states, and the revenue sparkline) rather than the former light paper and orange. Self-hosted Hanken Grotesk (display/body) and Space Mono (chrome/mono) replace Inter, loaded under the strict `font-src 'self'` policy. · *Components recoloured for the dark surface* — Stat cards, the orders table, status pills, filter chips, forms, buttons (primary/ghost/danger), banners, nav cards, the gift-card detail grid, and the sign-in card are all restyled for dark with contrast maintained. Order-status pills keep their semantics — green for paid/shipped/fulfilling/delivered, amber for pending, violet for refunded, muted for cancelled. Form inputs sit on recessed dark wells with light text and a violet focus ring.

- v0.2.2 (2026-05-27) — **Bespoke line-art illustrations across the storefront — category tiles, image placeholders, and every empty state.** The storefront's stand-in graphics are now a single, cohesive set of hand-drawn line illustrations in the brand violet, replacing the previous mix of gradient-only tiles, single-letter image marks, and emoji. The six featured-collection tiles carry a matching icon (apparel, hardware, digital, subscriptions, bundles, gift cards). Products without an image — in the grid and on the product page — show a bespoke "no image yet" placeholder instead of a letter glyph. Every empty state has its own illustration: cart, search, wishlist, saved-for-later, addresses, returns, and orders. The artwork is inline SVG (no extra files fetched, consistent with the strict asset policy) and adapts to the surface — the fine detail strokes follow the text colour, so the same set reads correctly on a dark or a light background. **Changed:** *Category tiles carry line-art icons* — Each of the six featured-collection tiles now shows a violet line icon for its category, centered over the existing tile gradient. The tile links and labels are unchanged. · *Bespoke image placeholder replaces the letter mark* — When a product has no image, the grid card and the product-page gallery render a line-art "no image yet" placeholder — an isometric package on a faint grid (the product page adds a shield motif and a terminal-prompt accent) — instead of the previous single uppercase letter. The no-image grid card now uses the same media-card layout as image-bearing cards for a consistent grid. · *Every empty state has matching artwork* — The empty cart, no-search-results, empty wishlist, saved-for-later, addresses, returns, and orders states now show a bespoke line illustration in place of the previous emoji or unicode glyphs, so the storefront and account sections share one visual language. · *Inline, theme-adaptive SVG* — All of the new artwork is inline SVG with presentation attributes only — nothing is fetched from a CDN and no inline styles are used, consistent with the strict content-security and asset policies. The detail strokes use currentColor, so the set adapts to the background it sits on. The edge and container renderers emit identical markup.

- v0.2.1 (2026-05-27) — **Theme tuning — stronger contrast, a cleaner product buy-box, and natural code-sample colors.** A polish pass over the new dark theme. The palette is rebalanced so the brand violet reads as a deliberate accent rather than the whole field: surfaces, borders, and shadows are now neutral dark, body text and prices are high-contrast near-white, and violet is concentrated on calls-to-action, the hero accent, and the newsletter band. The product page's buy-box is now a single contained card — price, variant, quantity, and add-to-cart group cleanly with proper spacing and alignment instead of blending into the column, and the wishlist/compare controls sit in their own separated row. The homepage code sample uses natural syntax-highlight colours (green strings, blue functions, violet keywords) with the usual window dots. The home collection tiles are calmer dark tiles with a subtle violet glow rather than solid magenta swatches. **Changed:** *Rebalanced palette for contrast* — Surfaces (`--surface`, card and raised tiers) and borders are now neutral dark instead of violet-tinted; secondary text and prices are high-contrast near-white; default shadows are neutral. The violet accent is reserved for primary buttons, the hero accent word, focus states, and the newsletter/framework bands — so contrast and hierarchy are clearer and the page no longer reads as a single wash of magenta. · *Product buy-box is a contained, aligned card* — Price, variant chip(s), quantity, and the full-width add-to-cart now sit in one bordered buy-box card with consistent spacing, the price rendered in high-contrast white, and the post-quantum-secured-checkout trust line as a calm note beneath it. The wishlist and compare controls are grouped into their own row, separated from the buy-box. No change to add-to-cart, variant, or currency behaviour. · *Natural code-sample colours and calmer collection tiles* — The homepage server-rendered code sample now uses a natural console palette — green strings, blue function names, violet keywords, grey comments — with red/yellow/green window dots. The home collection tiles are dark with a subtle, position-varied violet glow instead of solid violet gradient fills.

- v0.2.0 (2026-05-27) — **A new storefront visual identity — a dark, violet brand theme with self-hosted typefaces and a redesigned product buy-box.** The default storefront theme has been redesigned around the project's actual brand: a dark, near-black canvas with a violet-to-magenta accent system that matches the blamejs shield-and-terminal mark, replacing the previous light theme and its unrelated orange accent. The stylesheet is restructured onto a semantic design-token layer (a purpose tier — surface, text, border, accent, price — sitting over the raw palette), so colour, contrast, and surfaces are now consistent and themeable rather than hard-coded per component. Two distinctive open-licensed typefaces are self-hosted in place of Inter: Hanken Grotesk for display and body, Space Mono for the terminal-style chrome (navigation, labels, SKUs, prices). The product page's variant selector is rebuilt from a data table into a proper buy-box: a prominent price, the variant(s) as selectable chips, a full-width add-to-cart, and a post-quantum-secured-checkout trust line — the add-to-cart, variant, and currency-conversion behaviour is unchanged. Accessibility is preserved (contrast targets met on the dark surfaces, focus-visible, reduced-motion guards, the consent banner restyled as legible dark glass). Operators on the default theme get the new look on upgrade; a custom theme passed via the theme primitive is unaffected. **Changed:** *Dark violet brand theme replaces the light theme* — The default theme is now a dark, near-black storefront with a violet→magenta accent family (matching the logo's shield/terminal mark) carried across the chrome, hero, cards, forms, badges, switchers, and footer. The previous light canvas and its off-brand orange accent are gone. Depth comes from hard-edged and soft violet shadows and a faint circuit-grid texture; motion (a staggered page-load reveal, a glitch treatment on the hero accent word, a terminal cursor) is CSS-only and disabled under `prefers-reduced-motion`. · *Semantic design-token layer + self-hosted typefaces* — The stylesheet now has a primitive→semantic token structure (`--surface`/`--surface-raised`, `--text`/`--text-muted`, `--border`, `--color-accent`, `--color-price`, glow tokens) so components reference purpose, not raw values — fixing the previously undefined `--font-sans`/`--danger-l` tokens and a duplicated button definition along the way. Hanken Grotesk (display/body) and Space Mono (mono chrome) are self-hosted as OFL woff2, replacing Inter; nothing is fetched from a font CDN, consistent with the strict `font-src 'self'` policy. · *Product buy-box redesign* — The product page's variant table is replaced by a buy-box: a large monospace price, each variant as a selectable pill chip (with a clear selected state, fully operable with JavaScript off), a full-width add-to-cart, and a trust line stating the checkout is post-quantum secured. The underlying add-to-cart form, multi-variant selection, and multi-currency price formatting are unchanged; products with many variants keep a compact fallback. The PDP trust badge wording "Ships from origin" is replaced with a real delivery estimate.

## v0.1.x

- v0.1.38 (2026-05-26) — **Security hardening + accessibility and UI completeness across the storefront and admin.** This release closes two data-exposure gaps, tightens admin input validation, and brings a broad accessibility and visual-completeness pass to both the storefront and the operator console. A freshly issued gift-card code is no longer placed in a redirect URL where it could persist in browser history or server logs — it's now revealed once in the page itself. Signed-in order pages now enforce ownership so a customer can only view their own orders. On accessibility: admin tables carry proper header scope, every admin page has its own title, a skip-to-content link and current-page navigation state are in place, search-filter selection is exposed correctly to assistive technology, status changes are announced, and destructive actions (refunds, plan archival, endpoint deletion, subscription cancellation, address removal) now require a confirmation step. Several surfaces that shipped without styling — the faceted-search sidebar, the subscriptions page, loyalty and referral badges, and the locale switcher — are now fully styled; product cards gain depth, accent-colored text meets contrast requirements, touch targets meet the 44px minimum on mobile, and success messages and empty states are consistent. **Changed:** *Confirmation step on destructive actions* — Irreversible or money-moving actions now route through a server-rendered confirmation page that states the consequence before proceeding: order refunds and subscription-plan archival and webhook deletion in the admin, and subscription cancellation (including the immediate-cancel option, which now spells out the forfeited time) and saved-address removal (with an undo link) in the customer account. · *Success confirmations and consistent empty states* — Account actions — saving or removing an address, toggling the wishlist, moving a saved item, requesting a return (which now echoes the RMA code) — confirm success with an announced status message instead of redirecting silently. Empty states across the account area now share one consistent treatment. **Fixed:** *Accessibility across storefront and admin* — Admin data tables now use `scope` on column and row headers; every admin page renders its own document title; a skip-to-content link and `aria-current` navigation state are in place. On the storefront, search-facet links expose their selected state correctly to screen readers (replacing an invalid attribute), and account status changes are announced via live regions. Accent-colored text (prices, labels, links) now uses a darker shade that meets the 4.5:1 contrast minimum, and interactive controls meet the 44px touch-target minimum on small screens. · *Styling for surfaces that shipped unstyled* — The faceted-search sidebar, the subscriptions management page, loyalty and referral status badges, and the footer locale switcher now render with full styling instead of unstyled browser defaults. Product and collection cards gain a resting elevation, a duplicate button definition was consolidated to one consistent style, and disabled-button and placeholder text now meet contrast requirements. **Security:** *Gift-card one-time code is no longer exposed in a URL* — Issuing a gift card from the admin console previously redirected to a URL containing the plaintext code, which could persist in browser history, the redirect `Location` header, and access logs. The code is now shown once directly in the response body (the same pattern used for webhook signing secrets) and never placed in a URL. · *Order pages enforce ownership for signed-in customers* — `GET /orders/:id` now verifies that a signed-in customer owns the order before rendering it (returning 404 otherwise), closing a gap where an order's contents could be viewed by anyone with the order id. The guest post-purchase confirmation path is unchanged. · *Strict validation on admin money inputs* — Gift-card amounts and subscription-plan amounts/intervals/trials are now parsed with strict integer validation, so malformed input (e.g. `50abc`) is rejected rather than silently coerced.

- v0.1.37 (2026-05-26) — **Localized storefront — the UI chrome renders in the visitor's language, with a locale switcher and right-to-left support.** The storefront's interface chrome — the navigation, search controls, newsletter band, and footer — now renders in the visitor's language. The locale is resolved the same way at the edge and in the container: an explicit `?lang=` choice first, then a saved preference cookie, then the browser's Accept-Language header, then the operator's default. A locale switcher in the footer lets the visitor change it; the choice persists in a first-party cookie. Right-to-left languages (Arabic, Hebrew, Persian, …) flip the document direction automatically. Strings come from the operator's locale policy and translation rows, and any key without a translation falls back to the English baseline rather than showing a raw placeholder — so a partially translated catalog never renders broken text. The default locale renders at the edge for cookie-less visitors (so cached pages stay fast); a non-default choice is served by the container. Everything works with JavaScript off, and the localized markup is byte-identical across the edge and the container. **Added:** *Localized UI chrome + footer locale switcher* — Navigation, search labels, the newsletter band, and the footer render in the visitor's locale. A locale switcher in the footer lists the languages the operator has enabled (shown by their own names — "Deutsch", "Français"); selecting one persists the choice in a first-party cookie and returns the visitor to the page they were on. The locale resolves identically at the edge and in the container — `?lang=`, then the cookie, then `Accept-Language`, then the operator default — and a right-to-left language sets the document's `dir` so the layout mirrors correctly. The switcher and selection work without JavaScript. · *Operator locale policy with English-baseline fallback* — Enable languages by setting a locale policy (a default locale plus the supported list) and supplying translation rows for the chrome strings; the storefront resolves each string against the active policy, layered over the operator's translations, and falls back to the built-in English baseline for any key still missing — never a raw key or empty label. With no policy seeded the storefront renders the English baseline and shows no switcher, so the feature is opt-in and a fresh install is unaffected. `SHOP_DEFAULT_LOCALE` sets the default the edge serves to cookie-less visitors; set `SHOP_LOCALES` (the supported list) so the edge forwards a first-time visitor whose `Accept-Language` prefers a supported non-default locale to the container instead of caching the default. An explicit cookie or `?lang=` choice is always served by the container so edge caching stays simple.

- v0.1.36 (2026-05-26) — **Multi-currency display — shoppers browse prices in their own currency while you still charge in your base currency.** Shoppers can now pick a display currency from a switcher in the footer; every price on the storefront — product pages, the grid, search, and the cart — re-renders in that currency using the foreign-exchange rates you set, formatted with the right symbol, grouping, and decimal places for the locale. Conversion is display-only: the cart line and the eventual charge stay in your base currency, and a "Prices shown in EUR; you'll be charged in USD" disclosure makes that explicit whenever a conversion is in effect. The choice rides in a sealed first-party cookie and survives navigation; a per-currency rounding rule can snap converted prices to a tidy increment (for example Swiss 0.05 Rappenrundung). Every failure mode falls back to the base currency rather than showing a broken price: a currency with no rate on file, a rate that has expired, a currency you've removed from the allow-list, or a tampered cookie all render in your base currency. The switcher and all conversions render identically from the edge and the container, and the whole flow works with JavaScript off. **Added:** *Currency switcher + on-page price conversion* — A currency switcher in the footer lists the currencies you've enabled; choosing one `POST`s to `/currency`, stores the choice in a sealed cookie, and redirects back to the page the shopper was on. From then on product pages, the product grid, search results, and the cart display prices converted into that currency, each formatted for its locale (symbol, thousands separator, decimal places). When a conversion is active a "Prices shown in <display>; you'll be charged in <base>" note appears so the shopper always knows the charge currency. The switcher submits without JavaScript — the small enhancement script only auto-submits on change. · *Operator-set FX rates, display rounding, and a base-currency guarantee* — Set your base currency and the enabled list with `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or the `shop.base_currency` / `shop.currencies` config keys), and supply foreign-exchange rates yourself from whatever source you trust — the framework never reaches out to a rate feed. Rates are stored as integer basis points and conversion uses banker's rounding; an optional per-currency rounding rule snaps the result to a display increment (e.g. nearest 0.05). Conversion is strictly for display: the cart line's currency and amount, and therefore the amount charged at checkout, always stay in the base currency. Any currency without a usable rate — missing, expired, de-listed, or a tampered cookie — falls back to base-currency display instead of a broken or `NaN` price, so a stale or absent rate feed never breaks a priced page.

- v0.1.35 (2026-05-26) — **Cookie-consent banner — GDPR/ePrivacy consent with category opt-in, an audit ledger, and a preferences page.** Every page now carries a cookie-consent banner until the visitor decides. They can accept all, reject everything non-essential, or open preferences to toggle categories; the choice is stored in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies (session, CSRF, the consent choice itself) always work; analytics and marketing are opt-in and default-deny, and a Do-Not-Track or Global-Privacy-Control signal collapses them to denied regardless of any stored opt-in. A "Manage cookies" footer link reopens the preferences. The banner and its choice form are server-rendered and work with JavaScript off — the small enhancement script only hides the banner once a choice exists — and the banner renders identically from the edge and the container. A consent-gating hook lets future analytics/marketing scripts render only when their category is granted. **Added:** *Consent banner + preferences page* — A banner shows on every page until a choice is made: Accept all, Reject non-essential, or Manage preferences. `POST /consent` records the decision (validated; safe-redirect back to where the visitor was), and `GET /cookies` is a preferences page to review and change categories later, linked from the footer. The authoritative choice is a sealed, HttpOnly first-party cookie; a tampered or missing cookie simply reshows the banner rather than erroring. Every decision is also written to the consent ledger for the GDPR audit trail, best-effort so a ledger hiccup never blocks the choice from taking effect. · *Category opt-in with DNT / GPC honored* — Cookies are grouped into strictly-necessary (always on) plus toggleable categories (analytics, marketing, …) that are opt-in and default-deny. A `DNT: 1` or `Sec-GPC: 1` request header forces the non-essential categories to denied regardless of any stored opt-in. A server-side gating hook renders a category's script only when that category is granted, so future analytics/marketing islands are consent-gated by construction. · *Consent is anchored to a policy version* — Each stored decision records the cookie-policy version it was captured under. When an operator bumps the active version (the cookie policy materially changed), decisions taken under the old version stop being honored — the gate falls back to default-deny for the optional categories and the banner re-prompts so consent is re-collected, rather than coasting on opt-ins given under a superseded policy. The banner and preferences page keep working with JavaScript off; the enhancement script only suppresses the banner while the stored version still matches the active one.

- v0.1.34 (2026-05-26) — **Vendored blamejs refreshed to v0.13.0.** Refreshes the vendored blamejs stack from v0.12.11 to v0.13.0 — the single source of every security, crypto, and primitive the shop composes. No application API changes; the refresh keeps the shop current with upstream fixes and broadens the primitive surface the shop builds on. The vendored tree is verified against the release tag by the vendor-integrity gate, and the in-image smoke gate runs against the refreshed tree before any image ships. **Changed:** *Vendored blamejs v0.12.11 → v0.13.0* — The committed copy under `lib/vendor/blamejs/` is updated to the v0.13.0 release tag via the vendor-refresh entry point. Composed primitives are unchanged at the call sites the shop already uses; the refresh carries upstream maintenance and expands the available primitive surface for future shop features.

- v0.1.33 (2026-05-26) — **Search filters and synonyms — narrow results by facet, and match what shoppers mean, not just what they typed.** Search gains filters and smarter matching. A search result page now shows facet groups — collection, price range, and in-stock — as filter controls; choosing one narrows the results, the counts beside each option reflect the current result set, and choices combine across groups. Active filters show with a one-click clear and carry through pagination rather than dropping on page two. All filtering is server-side from query parameters, so it works with no client JavaScript. Separately, the query is expanded through an operator-curated synonym and typo vocabulary before the catalog is searched, so a search for "tee" matches "t-shirt" and a near-miss spelling still finds the product, with a "Showing results for" note when the query was corrected. Filters and synonym matching work identically whether the page is served from the edge or the container. This release also moves the shipped theme's stylesheets and island scripts to content-fingerprinted filenames so an asset and the page that references it can never fall out of sync. **Added:** *Faceted search filters* — A search result page shows facet groups — collection, price range, and in-stock availability — as server-rendered filter controls. Selecting a facet narrows the results and is reflected in the URL (for example `/search?q=shirt&collection=summer&in_stock=1`), the count beside each option reflects how many results it would yield against the current query, and facets combine across groups. Active filters render as removable chips with a clear-all path, and the active filters carry through result pagination so page two keeps the filter set. All filtering is server-side from the query string — no client JavaScript — and unknown facet keys, out-of-range price values, and garbage filter values are ignored rather than erroring. With filters applied and no matches, the page shows an empty state with a clear-filters link. · *Synonym and typo-tolerant query matching* — Before the catalog is searched, the query is expanded through an operator-curated vocabulary: synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal. When the query is corrected or expanded, the page shows a "Showing results for" note. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. **Changed:** *Search filters and synonyms render identically at the edge and the container* — The faceting and synonym logic runs in both the edge worker and the container search path, producing the same results and the same filter chrome whichever serves the page. The edge path reads the catalog and the facet/synonym vocabulary directly from the database, missing-table-resilient, so a shop without the optional facet configuration still searches normally. · *Theme stylesheets and scripts use content-fingerprinted filenames* — The shipped default theme's stylesheets and island scripts are now referenced by a content-hashed filename (`main.<hash>.css`) rather than a `?v=` query, emitted identically by the edge and container renderers and served from object storage under the fingerprinted key. Each URL maps to exactly one byte-content, so an asset and the page that references it can never disagree on a Subresource Integrity digest, and previously-served pages keep working across an asset change. The edge stylesheet link now also carries an integrity attribute, matching the container.

- v0.1.32 (2026-05-26) — **Bundles and quantity discounts — sell products together at a set price, and reward buying more.** Two merchandising surfaces go live on the product page and in the cart, both priced authoritatively on the server. A product that belongs to a bundle shows a "Bundle & save" offer with its member products and the bundle price against the sum of the parts; one action adds the whole bundle to the cart at that price, all-or-nothing, and the discount is split across the member lines so the cart subtotal equals the quoted bundle price. Separately, a product with quantity breaks shows its price tiers ("1–2 / 3–5 / 6+") on the page, and the cart applies the right unit price for each line's quantity — recomputed on every cart render and again at checkout, so the line, cart, and order totals all reflect the break. The client never sends a price: it sends a bundle code or a quantity, and the server prices both from the live catalog. A bundle whose member is archived or out of stock is shown unavailable rather than adding a broken line, and a missing offer simply renders nothing. **Added:** *Bundles on the product page* — A product that is part of one or more bundles shows a "Bundle & save" offer listing the bundle's member products, the bundle price, and the saving against buying the parts separately. `POST /cart/bundle` adds the whole bundle to the cart in one action: the bundle price is recomputed from the live catalog and allocated across the member lines (proportional to each member's list price, with the rounding remainder on the last line) so the cart subtotal equals the quoted price. The add is atomic — if any member is archived or out of stock the bundle is shown unavailable and the action adds nothing rather than a partial set. A product in no bundle shows no offer; an unpriceable bundle (a missing member price or a currency mismatch) is shown unavailable, never a server error. The offer renders identically from the edge worker and the container. · *Quantity discounts on the product page and in the cart* — A product with quantity breaks shows its price tiers on the page (for example 1–2, 3–5, 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — applied server-side on every cart render so changing the quantity re-prices the line, and applied again at checkout so the per-line price and the order total written onto the order reflect the break, not the list price. A quantity below the first tier falls back to the base price; a quantity above the top tier takes the top tier. Stacks with the rest of the cart math. The tier table renders identically from the edge worker and the container. **Fixed:** *Themed product template carries the compare control* — The file-backed default product template now includes the Add-to-compare slot, matching the built-in render path so the comparison control appears on the product page whichever template is in use.

- v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.

- v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.

- v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.

- v0.1.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.

- v0.1.27 (2026-05-25) — **Loyalty rewards go live — points earned on purchase, viewed in the account, and redeemed at checkout.** The loyalty points ledger now has a customer-facing surface. A new /account/loyalty page shows the signed-in customer's balance, tier, and earn/redeem activity, explains how points are earned, and lists the reward catalog with a one-click redeem. Paid orders award points automatically: the order primitive fans its paid transition into the earn rules (per-dollar-spent + per-order), fire-and-forget and deduplicated so a re-delivered payment webhook never double-credits. At checkout, a signed-in customer can spend points for a storefront credit against the order total — capped at the order's value and the balance, recorded once, with the same atomic double-spend guard the gift-card credit uses. **Added:** *Customer rewards page at /account/loyalty* — A login-gated page showing the points balance, current tier, lifetime points, and the cash value of the balance; how points are earned (the active earn rules in plain language); the reward catalog with a Redeem control per reward (disabled when the balance is short); the customer's past redemptions; and the earn/redeem ledger, paginated newest-first. Linked from the account dashboard as "Rewards". A redeem that the customer can't complete (not enough points, reward unavailable, per-customer limit reached) re-renders the page with the reason rather than erroring; a malformed history cursor degrades to the first page. · *Points earned automatically on paid orders* — When an order reaches paid, the configured earn rules award points to the customer's balance — per dollar of order subtotal and a flat per-order bonus, whichever rules the operator has defined. The award rides the order's paid transition (so every paid path — card, PayPal, gift-card-covered, admin — earns), runs fire-and-forget off the request path, and is keyed to the order so a re-delivered payment webhook collapses onto a single award instead of double-crediting. Guest orders (no customer account) earn nothing, by design. · *Redeem points at checkout as a credit* — A signed-in customer with a balance sees a "Redeem loyalty points" field on the checkout form. The points are valued via the ledger's redemption ratio (100 points = $1 by default), capped at the order total so the credit never exceeds what the order is worth, and the credit reduces the amount due. The order still records the full grand total it owed; the points are debited once, after the order row exists, behind the same balance-guarded SQL decrement that prevents a double-spend. Points worth more than the order spend only what the granted credit is worth — the surplus stays in the balance. A request for more points than the balance, or from a guest cart, is refused with a clean re-prompt. A points credit stacks with a gift-card credit; together they can pay an order in full with no card charge.

- v0.1.26 (2026-05-25) — **Fix the container build gate that rejected a manifest absent from the image.** The asset-integrity drift gate added last release checked both the container and edge copies of the manifest. The container image excludes the edge worker directory from its build context, so when the gate runs inside the image build it can't see the worker copy and read it as empty — failing the build. The gate now skips a manifest that isn't present in the current build context; its drift is still caught wherever the file exists (the full-tree CI run checks both). **Fixed:** *Asset-manifest drift gate no longer fails the container build* — `generate-asset-manifest.js --check` compared both manifest copies (`lib/` for the container, `worker/` for the edge) against a fresh rebuild. The container image's build context excludes the `worker/` tree, so inside the image the worker copy is absent and the gate read it as empty — reporting false drift and failing `node test/smoke.js`, which is a build gate, so the container image never built. The check now treats a manifest that isn't present in the current context as not-applicable and skips it (requiring at least one copy to be present), so the same drift detection still runs in CI and at the edge build where both files exist, without breaking the container image build.

- v0.1.25 (2026-05-25) — **Asset integrity and the cache-buster are correct in production now.** The renderers stamped a Subresource Integrity digest and a `?v=` cache-buster onto the theme stylesheet and island scripts by hashing the files at render time — which silently produced nothing in production, where the container image doesn't ship the theme files and the edge has no filesystem to read them. So the integrity attribute was absent live and edge-rendered pages pinned a `0.0.0` version that never changed between releases (a stylesheet update would render against a browser-cached copy). Both now come from a manifest built at release time and read directly by both runtimes. **Fixed:** *Subresource Integrity now applied on server-rendered pages* — The `integrity="sha384-…"` attribute on the default theme's stylesheet and island scripts was computed by reading the asset off disk during the render. That read fails in the deployed container (its image carries the code, not the `themes/` tree) and at the edge (no filesystem), so the attribute was silently dropped — the tamper-evidence it's meant to provide wasn't active. The digests are now computed once at release time into `lib/asset-manifest.json` (bundled for both the container and the edge worker) and read from there, so the browser actually verifies the served bytes against the pinned hash. · *Edge-rendered pages no longer pin a 0.0.0 cache-buster* — Pages rendered at the edge (home, product, search, the empty cart, blog) appended `?v=0.0.0` to the stylesheet URL because the worker had no release version to stamp — so the cache-buster never changed across deploys and a browser could keep serving an old stylesheet after a CSS update (markup against stale styling). The version now comes from the release manifest, so each release invalidates the cached asset as intended. The drift between the manifest and the on-disk assets is caught by a smoke gate (`generate-asset-manifest.js --check`).

- v0.1.23 (2026-05-25) — **The admin console renders styled again, and both surfaces now self-host their fonts.** The admin console shipped its design system as an inline <style> block, inline style="" attributes, and a cross-origin Google Fonts link — all three of which the strict `style-src 'self'` / `font-src 'self'` CSP that governs container-served routes refuses, so the console rendered as an unstyled page in production. Its styling now lives in an external stylesheet served from /assets (like the storefront's), and the typeface is self-hosted, so the console renders as designed under the same CSP that protects it. Both the storefront and the console now serve Inter from /assets rather than naming it and hoping the visitor has it installed. **Changed:** *Inter is self-hosted on both the storefront and the console* — The default theme named Inter / Inter Tight first in its font stack but shipped no font files, so the intended typography only rendered for visitors who happened to have Inter installed locally — everyone else fell back to a system sans-serif. The latin subset of Inter (400/500/600) and Inter Tight (600/700) now ships under `themes/default/assets/fonts` and is referenced via `@font-face`, so the typeface renders for every visitor and loads same-origin under `font-src 'self'` — no cross-origin font CDN on any page. The fonts are SIL OFL 1.1 (license alongside the files); `font-display:swap` paints text immediately in the system fallback and swaps when the file arrives. · *Admin console visual refresh* — With the stylesheet external it also got a pass: a proper centered sign-in card (replacing the bare form that was the first thing an operator saw), softer surfaces and shadows on cards and panels, a sticky console nav, and consistent spacing. The brand palette (ink + the storefront's accent) and every screen's structure are unchanged — this is presentation only. **Fixed:** *Admin console renders unstyled in production* — Every `/admin` screen built its design system three ways the strict CSP refuses: an inline `<style>` block, inline `style=""` attributes, and a `<link>` to fonts.googleapis.com. Container-served routes run under `style-src 'self'` / `font-src 'self'`, which drops all three — so the console arrived as unstyled HTML (a plain page) once deployed, even though it looked correct in isolation. The styling now ships as `/assets/themes/default/css/admin.css`, an external file `'self'` allows, linked with a `sha384` Subresource Integrity digest so the browser rejects a tampered or stale object; the inline attributes are gone, folded into that stylesheet. A render-shell test asserts every admin screen links the external stylesheet, carries no inline styles, and pulls no third-party font host, so the regression can't return. Operators redeploy to pick this up — `npm run deploy` uploads the new stylesheet (and the fonts below) to R2 as part of the asset sync.

- v0.1.22 (2026-05-25) — **Fix the container boot crash-loop, plus gift cards, subscription self-management, and a customers roster.** The deployable container could crash-loop on startup, taking every write route (add-to-cart, checkout, account, admin) down while edge-rendered reads kept working — this release fixes that boot path. It also adds gift cards (issue / balance / redeem-at-checkout with an operator ledger), customer subscription self-management at `/account/subscriptions`, and a read-only customers roster in the admin console. **Added:** *Gift cards — redeem at checkout, balance check, operator ledger* — A gift-card code can be applied at checkout as a credit against the order total: the amount due drops by the available balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order behind an atomic `balance >= amount` guard so concurrent spends can't overdraw — a card that fully covers the order is marked paid with no card charge. Customers check a balance at `GET /gift-cards` (which is not a code-existence oracle — unknown / malformed / expired all return the same generic result). Operators work `/admin/gift-cards`: list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a card (the bearer code shown once, right after creation), and open a card to read its full credit / debit / expire ledger. Codes are stored as a `namespaceHash` digest plus a 4-char hint — never in the clear. · *Customer subscription self-management* — Signed-in customers view and cancel their own subscriptions at `/account/subscriptions` — each row shows the plan summary, status, and renew / end date, with a Cancel control on cancelable subscriptions. Every action is ownership-checked: a subscription that doesn't belong to the signed-in customer is a 404, never another account's row. Cancellation runs through the payment provider, so the cancel control mounts only when the payment handle is wired; without it the list renders read-only. Customer-facing subscription creation — a subscription-checkout flow — remains a separate follow-up; this surface is for managing existing subscriptions. · *Customers admin roster* — `/admin/customers` renders a read-only account roster (newest first): display name, a short id, the join date, the sign-in method (passkey count plus any linked OAuth providers), and the order count, with cursor pagination in the browser and a JSON list (with `next_cursor`) for a bearer-token client. Account creation and changes stay with the storefront's passkey / OIDC ceremonies. Order counts and sign-in methods are resolved with bounded aggregate queries over each page's ids — no per-row database trips. Raw email is never stored, so it is never shown. **Changed:** *Console nav gains Customers and Gift cards* — The signed-in admin nav now includes Customers and Gift cards alongside the existing screens, each shown when its primitive is wired. All new endpoints content-negotiate like the rest of the console: a bearer-token client gets the JSON API, a signed-in browser gets HTML; a request without the bearer token returns the sign-in form on a GET and redirects on a write. The customers roster, the subscription cancel, and the gift-card balance page all degrade gracefully when an optional dependency (payment, or the primitive itself) isn't configured. · *Deploy now syncs theme assets to R2* — `npm run deploy` chains `wrangler deploy` with a new `scripts/sync-r2-assets.js` that uploads the repo's theme stylesheets to the R2 bucket the Worker serves `/assets/*` from. `wrangler deploy` alone ships the Worker + container but never touches R2, so a CSS change would silently drift from the deployed HTML — new markup against a stale stylesheet. Run `npm run sync-assets` to push the stylesheets without a full redeploy. · *Theme stylesheet and island scripts served with Subresource Integrity* — The default theme's stylesheet `<link>` and the externalized passkey island `<script>` tags now carry a `sha384` `integrity` attribute computed from the on-disk asset, so a browser refuses any `/assets/*` response whose bytes don't match — closing the window where a tampered or stale CDN object could execute against the page. The digests are computed once at boot and cached; a missing or unreadable asset degrades to no integrity attribute rather than blocking the render. **Fixed:** *Passkey sign-in / registration no longer blocked by CSP* — The WebAuthn passkey ceremonies on `/account/login` and `/account/register` were inline `<script>` blocks, which the storefront's strict `script-src 'self'` CSP refuses to execute — so on the deploy the sign-in and registration buttons did nothing (accounts are passkey-only, so this blocked all customer auth). The ceremonies now ship as external assets (`/assets/themes/default/js/passkey-login.js` + `passkey-register.js`) that `'self'` allows, served from R2 and uploaded by the deploy's asset sync. Same effect, CSP-clean — and a clearer split between the server-rendered form and its opt-in enhancement. · *Container no longer crash-loops on startup* — The container boot had three ways to fail before it could serve a request, each of which left edge-rendered reads working while every container-served route (add-to-cart, cart update / remove, checkout, `/account`, `/admin`) was unreachable. (1) The secure-default boot unlocks a wrapped vault **and** a wrapped audit-signing keypair, which the framework reads from `BLAMEJS_VAULT_PASSPHRASE` / `BLAMEJS_AUDIT_SIGNING_PASSPHRASE`, but the deploy contract documents a single `VAULT_PASSPHRASE` the framework never read — so the container had no passphrase source, no TTY to prompt, and exited. The entry point now bridges the documented secret onto the vault passphrase and derives a domain-separated audit-signing passphrase from it. (2) Encrypted-at-rest requires a tmpfs; the image now points it at `/dev/shm` rather than failing to find one. (3) The Docker liveness probe was `wget`, which bot-guard blocks as automation (403), so a healthy container was marked unhealthy and stopped — the probe is now a browser-shaped Node request (`scripts/healthcheck.js`) that passes bot-guard like real traffic, without exempting the health path from the security middleware. A boot gate (`test/layer-2-integration/server-boot.test.js`) spawns the real entry point and asserts it listens, answers the probe, and still blocks header-less clients. Operators must redeploy the container to pick this up.

- v0.1.21 (2026-05-25) — **Admin console — a collections screen.** Collections join the admin console. `/admin/collections` lists manual and smart product collections, creates either kind, edits title / description / sort strategy, archives one, manages a manual collection's members (add / remove / reorder), and builds a smart collection's rules with a live preview of the products they match — all from a signed-in browser. As with the other console screens, the same paths serve the existing JSON API to a bearer-token client unchanged. **Added:** *Collections management screen* — `/admin/collections` lists the shop's collections (title, slug, type, status, member or match count) with an all / active / archived filter; `/admin/collections/:slug` opens one. For a manual collection it manages members — add a product by id, remove, and reorder. For a smart collection it edits the rule set (field / operator / value rows over the supported fields and operators) and renders a live preview of the products the rules currently match. A form creates either kind; editing updates the title, description, and sort strategy (and rules for smart collections); and a collection can be archived. A bad-shape submit re-renders with the validator's message rather than a 500, and an unknown slug or product is a no-op notice. **Changed:** *Console nav gains Collections* — The signed-in admin nav now includes Collections, shown when the collections primitive is wired. The list, detail, create, edit, archive, and member endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write.

- v0.1.20 (2026-05-25) — **A "Customers also bought" rail on the order confirmation.** The order confirmation page now shows a recommendation rail. After a purchase, `/orders/:id` surfaces up to four products drawn from operator-curated pins first, then co-purchase signals from the order's own items (what other shoppers bought alongside them), then category-popular and in-stock fallbacks — always excluding what was just bought. The rail is best-effort: if the engine isn't wired it simply doesn't render. **Added:** *Order-confirmation recommendation rail* — The post-purchase page renders a "Customers also bought" rail beneath the order summary, anchored on the order's items. Picks come from the recommendations engine: operator-curated overrides first, then a co-purchase signal (products bought in the same orders), then category-popular and in-stock fallbacks to fill the rail; the order's own products are always excluded. Each pick reuses the storefront product-card markup (image, title, price) and links to the product page. The rail renders in both the default and file-backed theme layouts; a store without the recommendations primitive wired renders no rail (and a read failure degrades to none rather than erroring the confirmation page).

- v0.1.19 (2026-05-25) — **Admin console — a webhooks screen, and order events now fan out.** Webhooks join the admin console. `/admin/webhooks` registers an endpoint (with a one-time signing-secret reveal), lists endpoints, enables / disables and deletes one, and opens an endpoint's delivery feed to retry a failed delivery. Order lifecycle transitions now fan out to registered endpoints — the order primitive is wired to the webhook dispatcher so a paid / fulfilled / shipped / delivered / cancelled / refunded transition produces a signed delivery. **Added:** *Webhooks management screen* — `/admin/webhooks` registers an outbound endpoint — an https:// URL plus the events to subscribe (or all) — and shows the HMAC-SHA3-512 signing secret once on creation; the secret is never rendered in the endpoint list afterward. The list shows each endpoint's URL, events, status, and per-minute rate, with actions to enable / disable, delete, and open its deliveries. The delivery feed lists each attempt (event, status, response code, attempt count, last error, time) and retries a failed delivery. The JSON API a bearer-token client already used — create / list / update / delete / deliveries / retry — is unchanged; the browser screen content-negotiates on the same paths. · *Order events fan out to webhooks* — The order primitive is now wired to the webhook dispatcher, so an order transition (`order.mark_paid`, `order.start_fulfillment`, `order.mark_shipped`, `order.mark_delivered`, `order.cancel`, `order.refund`) produces a signed delivery to every subscribed endpoint. Dispatch is post-persist — a delivery failure can never roll back the transition that landed — and a failed delivery is recorded for retry from the console rather than surfaced to the request that triggered it. **Changed:** *Console nav gains Webhooks* — The signed-in admin nav now includes Webhooks, shown when the webhooks primitive is wired. Endpoint create, list, enable / disable, delete, the delivery feed, and retry content-negotiate like the other console screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write. An https-only URL or empty event set re-renders the form with the validator's message, and an unknown endpoint or delivery is a no-op notice rather than a 500.

- v0.1.18 (2026-05-25) — **Admin console — a subscription-plans screen.** Subscription plans join the admin console. `/admin/subscription-plans` lists the recurring-offer catalog — price, interval, trial, and Stripe price id — with an active / archived filter, creates a plan from a signed-in browser, and archives one. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Subscription-plans management screen* — `/admin/subscription-plans` renders the plan catalog (price and interval, trial length, Stripe price id, linked variant, status) with an active / archived filter, when opened in a signed-in browser; the same path serves the JSON list to a bearer-token client. A form creates a plan — Stripe price id, interval, interval count, currency, amount, trial days, and an optional variant link — and each active row archives in one submit. A bad-shape create re-renders the form with the validator's message rather than a 500, and archiving an unknown plan is a no-op notice. Archiving is terminal from the console: because a plan mirrors a Stripe price id that can go stale, a retired plan is re-offered by creating a new one against a fresh price id. **Changed:** *Console nav gains Subscriptions* — The signed-in admin nav now includes Subscriptions alongside Products, Inventory, Orders, Returns, and Reviews, shown when the subscriptions primitive is wired. The `/admin/subscription-plans` list, create, and archive endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write.

- v0.1.17 (2026-05-25) — **Admin console — an inventory screen.** Inventory joins the admin console. `/admin/inventory` lists stock per SKU — on hand, held, and available — with a low-stock filter, restocks a SKU, sets or clears its per-SKU low-stock threshold, and tracks a new SKU, all from a signed-in browser. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Inventory management screen* — `/admin/inventory` renders the inventory table (SKU, on-hand, held, available) with a low-stock filter (`?low=1`) that surfaces SKUs at or below their threshold, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each row's form restocks (add quantity) and sets the low-stock threshold (blank clears it) in one submit, and a form below tracks a new SKU. The create / restock JSON API is unchanged for tooling; the browser forms post and redirect (PRG), and a bad SKU is a no-op notice rather than a 500. · *catalog.inventory.list* — `catalog.inventory.list({ limit, low_only })` returns inventory rows (SKU ascending), optionally only those at or below their configured low-stock threshold — the read backing the console list and the JSON endpoint. **Changed:** *Console nav gains Inventory* — The signed-in admin nav now includes Inventory alongside Products, Orders, Returns, and Reviews. The `/admin/inventory` list and the create / restock endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token on these paths now returns the sign-in form on a GET and redirects on a write, matching the other console screens.

- v0.1.16 (2026-05-25) — **PayPal express checkout — the on-page button.** PayPal checkout is now usable from the storefront. When PayPal is configured, the checkout page shows a native PayPal button (distinct from PayPal-through-Stripe): it opens a PayPal order for the current cart, the buyer approves in the PayPal popup, and the order is captured and marked paid. A verified PayPal webhook is the asynchronous backstop. This completes the native PayPal integration on top of the adapter and checkout orchestration shipped in the previous two releases. Card / Stripe checkout is unchanged. **Added:** *PayPal button + create/capture routes on the storefront* — The checkout page renders a PayPal button when `PAYPAL_CLIENT_ID` is configured. Its create step posts to `POST /checkout/paypal/create` (prices the cart, opens a PayPal order, persists the local order pending) and its approve step posts to `POST /checkout/paypal/capture` (captures and advances the order to paid, then redirects to the order page). Both validate input and never 500 on a missing cart or id. The buttons collect the same shipping fields as the card form. · *PayPal webhook endpoint* — `POST /api/webhooks/paypal` is the asynchronous backstop for captures completed or refunded out of band. The container verifies each event server-to-server through PayPal's verify-webhook-signature API (no edge HMAC pre-check, unlike Stripe), then advances the order; re-deliveries are idempotent. Point a PayPal webhook at `/api/webhooks/paypal`. **Changed:** *PayPal listed as configurable; CSP note* — The integrations status page and README document PayPal as a first-class checkout option once configured. As with the Stripe pay page's `js.stripe.com`, operators must allow `www.paypal.com` in their Content-Security-Policy `script-src` / `frame-src` for the PayPal SDK and approval popup.

- v0.1.15 (2026-05-25) — **PayPal checkout orchestration.** Checkout can now run a PayPal order end to end, building on the adapter from the previous release. The orchestrator prices the cart, opens a PayPal order and persists the local order as pending, captures it after the buyer approves and advances the order to paid, and has a webhook backstop for captures completed or refunded out of band. Wired in when a PayPal app's credentials are present, and surfaced on the integrations status page. The storefront button and routes that drive this from the pay page come next; card / Stripe checkout is unchanged. **Added:** *checkout PayPal methods* — With a PayPal adapter wired (`paypal` dep), checkout gains three methods. `createPaypalOrder({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key, return_url?, cancel_url? })` prices the cart, opens a PayPal Orders-v2 order, persists the local order in `pending` with the PayPal order id linked, and marks the cart converted. `capturePaypalOrder(paypalOrderId)` captures the approved order and advances the local order to `paid` (idempotent — a retry or a webhook that beat it won't double-transition). `handlePaypalEvent({ headers, rawBody })` is the asynchronous backstop: it verifies the event through PayPal, then maps `PAYMENT.CAPTURE.COMPLETED` → paid and `PAYMENT.CAPTURE.REFUNDED` → refunded, idempotent across re-deliveries. · *PayPal wired from configuration* — When `PAYPAL_CLIENT_ID` + `PAYPAL_SECRET` are set (with `PAYPAL_ENV` and `PAYPAL_WEBHOOK_ID`), the server builds the PayPal adapter and passes it to checkout; the integrations status page lists PayPal as action-needed once card checkout is also live. Off until configured; the existing checkout flow is untouched.

- v0.1.14 (2026-05-25) — **PayPal payment adapter (Orders v2).** `payment.create({ adapter: "paypal", … })` is a new native PayPal adapter alongside the Stripe one — a from-scratch Orders-v2 client over the framework's SSRF-gated HTTP client, with no PayPal SDK dependency. It exchanges an OAuth2 client-credentials token (cached until it nears expiry), creates and captures orders, fetches and refunds them, and verifies inbound webhooks through PayPal's verify-webhook-signature API. This ships the adapter only; wiring it into the checkout flow and a storefront button comes next. Card / Stripe checkout is unchanged. **Added:** *PayPal Orders-v2 adapter* — `payment.create({ adapter: "paypal", clientId, secret, sandbox?, webhookId?, apiBase? })` returns `{ createOrder, captureOrder, getOrder, refund, verifyWebhook }`. `createOrder({ amount_minor, currency, order_id?, return_url?, cancel_url? })` opens a CAPTURE-intent order (amounts converted to PayPal's decimal-string major units, including 0-decimal currencies); `captureOrder(id)` finalizes it; `refund({ capture_id, amount_minor?, currency? })` refunds full or partial; `getOrder(id)` reads status. Every call carries an OAuth2 bearer token exchanged once and cached until ~2 minutes before expiry, and a `PayPal-Request-Id` for idempotency (plus the shared idempotency cache when a `query` handle is wired). `verifyWebhook(headers, rawBody, { webhookId })` confirms an inbound event through PayPal's verify-webhook-signature API and returns `{ ok, event }`. Outbound HTTP goes through `b.httpClient` — no `paypal` npm dependency. Off until the operator supplies credentials; the Stripe adapter and existing checkout are unchanged.

- v0.1.13 (2026-05-25) — **Internal: uniform framework access across the library.** An internal consistency refactor with no API or behavior change. Every library module now captures the framework once at the top — straight from the vendored tree (`var b = require("./vendor/blamejs");`) — and uses `b.*` uniformly, replacing a per-module lazy accessor and its scattered call sites. Capturing the framework directly (rather than via the composing entry point) also avoids a circular-load edge case on leaf-first imports. Two source files that embedded a raw NUL byte as a map-key separator now use the `\u0000` escape, so the whole library is plain text. New lint rules lock all of this in place. Public APIs, runtime behavior, and the published surface are unchanged. **Added:** *Lint detectors for accessor uniformity and source hygiene* — Three repository lint rules now prevent drift: one rejects the reintroduction of the per-module lazy framework accessor (capture the framework once at module top); one rejects requiring the composing entry point from a leaf module (require the vendored framework directly — entry-point requires from a leaf create a circular-load hazard); and one rejects raw C0 control bytes in source files (write `\u0000` and friends as escapes so files stay plain text — grep / diff / editors handle them correctly). **Changed:** *Framework handle captured once per module* — Library modules previously reached the vendored framework through a lazy `_b()` accessor invoked at every call site. They now capture it once at module top — `var b = require("./vendor/blamejs");`, the same object the entry point re-exports as `.framework` — and reference `b.*` directly, matching how the edge worker already accesses it. Capturing it directly from the vendored tree (instead of through the composing entry point) keeps leaf-first module imports working — requiring a single module no longer pulls the entry point's whole assembly mid-initialization. No public API or runtime behavior changes.

- v0.1.12 (2026-05-25) — **Card payments now finalize the order — the Stripe webhook is handled end to end.** A confirmed Stripe payment now advances the order from pending to paid. The container now serves the `POST /api/webhooks/stripe` route the edge worker forwards to: it re-verifies the event signature over the exact raw bytes, maps the event to the order's FSM transition, and is idempotent across Stripe's re-deliveries. Previously the edge verified the webhook but nothing consumed it on the container, so a paid PaymentIntent (card, Apple Pay, or Google Pay) left the order stuck in pending — no fulfillment, no paid status. Operators running checkout should upgrade and confirm their Stripe webhook points at `/api/webhooks/stripe`. **Added:** *Raw-body capture for payment webhooks* — A small middleware preserves the exact request bytes for the webhook path before the JSON body-parser runs, so signature verification (which is computed over the raw body) is reliable. It is scoped to the webhook routes and leaves every other request untouched. **Fixed:** *Stripe webhook completes the order* — `POST /api/webhooks/stripe` is now handled on the container: the event signature is re-verified against `STRIPE_WEBHOOK_SECRET` over the raw request body (a tampered or unsigned event is rejected with 400), then `payment_intent.succeeded` / `.canceled` / `charge.refunded` drive the order FSM (`mark_paid` / `cancel` / `refund`). Re-deliveries are idempotent — an event for an order already in the target state is acknowledged with 200 and skipped. A delivery for an unknown PaymentIntent is acknowledged without effect. This closes the gap where a confirmed payment never moved the order out of `pending`.

- v0.1.11 (2026-05-25) — **Sign in with Apple.** Customers can now sign in with Apple, alongside passkeys and Google. A “Continue with Apple” button appears on the account login page once the operator wires the Apple credentials; the callback turns the verified Apple identity into a shop session, adopts the guest cart, and claims prior guest orders placed under the same verified email — the same way Google sign-in does. Apple's OAuth client secret is itself an ES256 JWT, which the shop mints from the team's Sign-in-with-Apple key. Sign in with Apple is off until the credentials are set, like every other integration. **Added:** *Sign in with Apple (OIDC)* — `GET /account/login/apple` starts the flow (sealed in-flight state cookie, PKCE, nonce); Apple posts the result back to `POST /account/auth/apple/callback` (response_mode=form_post). The callback verifies the state, exchanges the code, signs the customer in on `(provider=apple, subject)`, adopts the guest cart, and — when Apple reports the email as verified — claims prior guest orders under that email. The display name is captured from Apple's first-authorization `user` field (Apple sends it only once and never in the ID token). The button appears on `/account/login` only when the credentials are configured, and is listed on `/admin/integrations`. · *customers.mintAppleClientSecret* — Mints Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (team id, key id, client id). This is the one signature the protocol forces to be classical ECDSA P-256 rather than the framework's post-quantum default — an external identity provider's wire format, not an application default. The secret is minted at boot with a 150-day life (inside Apple's six-month ceiling) and re-minted on each deploy. **Changed:** *Account login offers Apple when configured* — The login page shows Continue-with-Google and Continue-with-Apple buttons independently, each gated on its own credentials. Set `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your Services ID), `APPLE_PRIVATE_KEY` (the `.p8` contents), and `SHOP_ORIGIN`, and add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL on the Services ID. Requires an Apple Developer Program membership. See the README “Optional integrations” section.

- v0.1.10 (2026-05-25) — **Admin console — a review moderation screen.** Reviews join the admin console, completing the set of management screens. `/admin/reviews` is the moderation queue: filter by status (pending, published, rejected) and act on each submission inline — publish it, reject it with a reason, or take a published one back down. Reviews are short, so the queue shows each one in full (rating, title, body, verified-purchase flag) with its actions, no separate detail page. As with the other screens, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Review moderation screen* — `/admin/reviews` renders the review queue as inline cards (rating stars, title, body, verified-purchase flag, product, date) with status-filter chips, when opened in a signed-in browser; the same path serves the existing JSON list to a bearer-token client. Each card offers the actions that fit its status — a pending review can be published or rejected, a published one taken down, a rejected one published — with Reject taking a reason. Publish and reject post to their endpoints and redirect (PRG); a missing id is a no-op notice, never a 500. **Changed:** *Console nav gains Reviews; the reviews API content-negotiates* — The signed-in admin nav now includes Reviews — shown, like Returns, only when the reviews primitive is wired. The `/admin/reviews` list and the publish / reject endpoints serve the HTML console to a signed-in browser while continuing to serve the JSON API to a bearer-token client unchanged. A request without the bearer token is no longer answered with a 401 on the list path — a browser GET receives the sign-in form — matching the other console screens.

- v0.1.9 (2026-05-25) — **Admin console — a returns moderation screen.** Returns join the admin console. `/admin/returns` is the RMA moderation queue: filter by status (pending, approved, received, refunded, rejected), and open a request to see its items, reason, customer notes, and linked order. From the detail page an operator works the return through its lifecycle — approve with a refund amount, mark the goods received, record the refund, or reject with a reason shown to the customer — with only the moves legal from the current status offered. An illegal move or a bad id is refused with a notice rather than an error. As with Products and Orders, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Returns moderation screen* — `/admin/returns` renders the RMA queue as a table (RMA code, order, reason, status, item count, refund amount, requested date) with status-filter chips, when opened in a signed-in browser; the same path serves the existing JSON list to a bearer-token client. Each return links to a detail page showing its line items, reason and customer notes, linked order, and refund details, with the legal next actions as forms — Approve takes a refund amount and currency, Reject takes a reason, Mark received and Refund are single-click. Each posts to its own endpoint and redirects (PRG); an unknown id renders a 404 page and an illegal or refused action redirects back with a notice, never a 500. · *returns.transitionsFrom* — `returns.transitionsFrom(status)` returns the moderation events legal from a given status as `{ on, to }`, derived from the same transition table the setters use — so the console's action buttons stay in lockstep with the return state machine. **Changed:** *Console nav gains Returns; the returns API content-negotiates* — The signed-in admin nav now includes Returns alongside Home, Dashboard, Products, Orders, Integrations, and Setup — shown only when the returns primitive is wired, so the link never points at an unmounted route. The `/admin/returns` list, detail, and approve / received / refund / reject endpoints now serve the HTML console to a signed-in browser while continuing to serve the JSON API to a bearer-token client unchanged. A request without the bearer token is no longer answered with a 401 on these paths — a browser GET receives the sign-in form and a write redirects to the console, matching the other console screens.

- v0.1.8 (2026-05-25) — **Admin console — an orders screen with full lifecycle control.** Orders join the admin console. `/admin/orders` lists recent orders newest-first with one-click status filters, and each order opens to its line items, totals, shipping address, and payment reference. From the detail page an operator drives the order through its lifecycle — mark paid, start fulfilment, mark shipped, mark delivered, cancel, refund — with only the moves that are legal from the current status offered as buttons; an illegal move is refused with a notice rather than an error. Refund moves money, so the console Refund button issues a real payment-provider refund (and only appears when a provider is wired and the order has a captured intent) before the order advances to refunded. As with Products, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Orders management screen* — `/admin/orders` renders recent orders as a table (order, status, item count, total, placed date) with status-filter chips, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each order links to a detail page showing its line items, subtotal / tax / shipping / total, shipping address, and linked payment intent, with the order's legal next transitions as action buttons that post back and redirect (PRG). The Refund button posts to the payment-refund flow — it issues the provider refund and then advances the order, so a console refund never marks an order refunded without moving the money; it appears only when a payment provider is wired and the order has a captured intent (partial refunds remain on the JSON API). A bad or unknown order id renders a 404 page, never a 500; an illegal or failed action redirects back with a notice and leaves the order unchanged. · *order.listRecent + order.transitionsFrom* — `order.listRecent({ limit, status })` returns recent orders across all customers (guest orders included), newest-first, optionally filtered to one status, with line items and shipping hydrated. `order.transitionsFrom(status)` returns the lifecycle moves legal from a given status as `{ on, to, label }` — both derived from a single order-FSM edge list, so the console actions stay in lockstep with the state machine. **Changed:** *Console nav gains Orders; dashboard orders link through* — The signed-in admin nav now includes Orders alongside Home, Dashboard, Products, Integrations, and Setup. The dashboard's recent-orders list links each row to its order detail.

- v0.1.7 (2026-05-25) — **Admin console — persistent navigation and a products screen.** The admin is now a cohesive, navigable web console rather than a set of disconnected pages. A persistent nav (Home, Dashboard, Products, Integrations, Setup) runs across every signed-in page, and Products is the first full management screen: browse the catalog, create a product, and archive or restore one — all from the browser. The JSON API is unchanged: an endpoint serves the HTML console to a signed-in browser and JSON to a bearer-token client, so tooling keeps working exactly as before. **Added:** *Console navigation* — Every signed-in admin page shares a top nav with the current section highlighted, so the dashboard, products, integrations, and setup wizard are one console. The shell is the same brand-matched layout as the dashboard. · *Products management screen* — `/admin/products` renders the catalog as a table (title, slug, status) with Archive / Restore actions and a New-product form, when opened in a signed-in browser. The same path returns the existing JSON list to a bearer-token client; create, archive, and restore content-negotiate the same way (browser form → redirect; bad input re-renders with a notice, never a 500). The product create / archive / restore JSON API is unchanged.

- v0.1.6 (2026-05-25) — **Claim past guest orders when a shopper signs in.** A shopper who checked out as a guest and later signs in with a provider-verified email now finds those past orders attached to their account. Checkout records a hash of the buyer's email on the order; on Google sign-in, orders placed under that same email — and not yet owned by anyone — are claimed into the account. Linking happens only on an email the identity provider verified, never on an unverified one, so it can't be used to steal another shopper's order history. **Added:** *Guest-order reconciliation on sign-in* — Orders now carry a `customer_email_hash` (migration 0206), written at checkout from the buyer's email with the same key the customers table uses. On a verified Google sign-in, `order.linkGuestOrdersByEmailHash` claims every ownerless order under that email into the account — so prior guest purchases appear in /account and satisfy customer-scoped checks (e.g. verified-buyer reviews). Only ownerless orders are touched (an order already attached to a customer is never reassigned), and only a provider-verified email triggers a link.

- v0.1.5 (2026-05-25) — **Tell operators how to turn each integration on.** Every third-party integration (Stripe card checkout, Apple/Google Pay, Sign in with Google) is off by default and only activates when you supply its credentials. This release documents exactly what to set for each — in the README, in a new .env.example, and in the admin console itself. A signed-in operator opens /admin/integrations to see, at a glance, which integrations are live and the precise environment variables (or one-time action) needed to enable the rest. Nothing is enabled without your keys. **Added:** *Admin integrations status page* — `/admin/integrations` lists each integration with a live Enabled / Not configured status and the exact variables or action to turn it on — Stripe keys, the payment-method-domain registration for wallets, the Google OAuth client + redirect URI. Read-only; secrets are never rendered. Linked from the admin landing. · *Operator setup docs* — A README "Optional integrations" section and a top-level `.env.example` enumerate every environment variable, what capability it unlocks, and the external setup (e.g. the Google OAuth redirect URI, the Stripe webhook path). Both note that Apple sign-in + PayPal are planned and that Shop Pay / "Sign in with Shop" isn't available to a self-hosted store.

- v0.1.4 (2026-05-25) — **Sign in with Google.** Customers can sign in with Google alongside passkeys. The account login page gains a Continue with Google button; the OIDC authorization-code flow (PKCE, state, nonce, ID-token verification) runs through the framework's OAuth adapter, and the verified identity becomes a shop session. Accounts are keyed on the provider's stable subject, and an existing account is only ever linked on an email the provider has verified — an unverified email that collides with an existing account is refused rather than linked. A cart built before signing in is adopted into the account, so checkout attaches the order to the customer. **Added:** *Google sign-in* — Mounts `/account/login/google` + `/account/auth/google/callback` when the operator sets `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, and `SHOP_ORIGIN`. The in-flight state (CSRF state + nonce + PKCE verifier) rides a sealed, /account-scoped, SameSite=Lax cookie; the callback verifies the state before exchanging the code. A forged or stale callback is dropped to the login page. On success the guest session cart is adopted into the account (`cart.setCustomer`), matching the passkey path. · *Federated identity model + safe account linking* — `customers.signInWithOIDC` resolves a verified sign-in to a customer: an existing `(provider, subject)` link, else — only on a provider-verified email — an existing account with that email, else a new account. It never links to an existing account on an unverified email (account-takeover defense). New `customer_oauth_identities` table (migration 0205) + `customers.byOAuthIdentity`.

- v0.1.2 (2026-05-25) — **Apple Pay and Google Pay express checkout.** The pay page now offers one-tap wallet checkout. Stripe's Express Checkout Element renders Apple Pay and Google Pay buttons above the card form on eligible devices, confirming the same PaymentIntent as the card path — so the webhook and order flow are unchanged. To turn the wallets on, the operator registers the shop's web domain with Stripe once via the admin API; Stripe performs Apple merchant validation and hosts the domain-association file, so no Apple Developer account is needed. **Added:** *Wallet buttons on the pay page* — The Stripe Express Checkout Element mounts above the card form and auto-renders Apple Pay / Google Pay (and Link) when the device and the shop's registered domain make them available. It stays hidden until Stripe reports an available wallet, and confirms the existing per-order PaymentIntent — the payment-completion path (webhook → order FSM) is identical to the card flow. · *Payment-method domain registration* — `POST /admin/payment-method-domains` (with `{ "domain_name": "shop.example.com" }`) registers a domain with Stripe to enable the wallets; `GET /admin/payment-method-domains` lists registered domains and their per-method status. The payment adapter gains `registerPaymentMethodDomain` + `listPaymentMethodDomains`. Apex, www, and each subdomain register separately; a live-mode registration also covers sandbox.

- v0.1.1 (2026-05-25) — **Admin setup wizard + a browser-accessible admin console.** The admin is now reachable from a browser, not just the bearer-token JSON API. Sign in once at /admin by pasting the ADMIN_API_KEY and a sealed, /admin-scoped session cookie carries you through the guided setup wizard (shop name, contact email, default currency, support URL — saved to shop config) and the analytics dashboard. The shop name set in the wizard drives the storefront header, page titles, and the admin header. **Added:** *Browser admin sign-in* — `/admin` renders a sign-in form; pasting the ADMIN_API_KEY sets a sealed `shop_admin` session cookie (SameSite=Strict, scoped to /admin) so the rendered admin pages are reachable from a browser. The JSON API stays bearer-only; the dashboard accepts either the cookie or the bearer token. · *Setup wizard* — `/admin/setup` is a guided form for the shop's core identity — name, contact email, default currency, support URL — validated (ISO-4217 currency, RFC-shaped email, http(s) support URL) and saved to shop config. The landing nags until setup is complete; the shop name then drives the storefront and admin headers.

- v0.1.0 (2026-05-25) — **Responsive cart + storefront cookie handling moved onto the framework primitive.** A storefront polish pass. The cart, order-confirmation, and account-history tables now reflow into stacked, labelled cards on phones and fit their column on wider screens — no more inner horizontal scroll. The quantity field reads clearly and accepts up to 99,999. The line actions (Update / Save for later / Remove) are compact with a clear hierarchy. Under the hood, all storefront cookie handling now composes the framework's cookie primitive (RFC 6265 parse/serialize plus vault-sealed read/write) instead of hand-built headers, and a malformed session cookie can no longer turn the cart — or any page that shows the cart count — into a 500. **Changed:** *Quantity field is readable and accepts up to 99,999* — The cart quantity input is wide enough to read a five-digit quantity, and the per-line maximum is raised to 99,999 (enforced on the server). · *Compact, clearly-ranked line actions* — Update is a small accent button, Save for later a quiet secondary, Remove a danger-tinted secondary — so the primary checkout call stays dominant. · *Cookie handling composes the framework primitive* — Session, authentication, WebAuthn-challenge, and payment cookies are all parsed and written through the framework cookie primitive (sealed read/write for the authenticated-session cookie), replacing hand-built Set-Cookie strings and manual header parsing. Cookie lifetimes are expressed through the framework's duration constants. **Fixed:** *Cart tables no longer scroll sideways* — The cart, order-confirmation, and account order-history tables reflow into one labelled card per row below 48rem and are sized to fit their column on wider layouts, so content is never trapped behind an inner horizontal scrollbar. · *Malformed session cookie no longer 500s the storefront* — A `shop_sid` cookie carrying a value that isn't a well-formed session id now reads as "no session" instead of reaching the cart lookup (which rejected it and surfaced a 500 on every page that renders the cart count). · *Account dashboard controls wrap on narrow screens* — The row of account actions (Wishlist / Saved for later / Recently viewed / Addresses / Returns / Sign out) now wraps instead of overflowing the viewport on a phone.

## v0.0.x

- v0.0.129 (2026-05-25) — **Recently viewed — a signed-in customer's browse history at /account/recently-viewed.** Signed-in customers now have a browse history. Opening a product page records the view against the customer's account, and `/account/recently-viewed` lists those products newest-first as a grid, with a one-tap Clear history control. Recording is best-effort and never blocks the product page; archived products drop out of the grid; the page is login-gated like the rest of the account area. **Added:** *Recently viewed account page* — `GET /account/recently-viewed` renders the customer's most-recently-opened products newest-first, reusing the standard product card (image, title, price, link to the PDP). A `POST /account/recently-viewed/clear` control wipes the history. Linked from the account dashboard. · *Server-side view recording* — A signed-in customer's product-page visit records the view against their account — no client script. Recording is drop-silent: a write failure never breaks the product page. The history de-dupes per product and is capped per customer.

- v0.0.128 (2026-05-25) — **Collections — browse curated and smart product lists at /collections.** The storefront now has real collection pages. `/collections` lists the shop's active collections, and `/collections/:slug` shows a collection's products as a grid — resolving both operator-curated manual collections (hand-picked members) and smart collections (rule-matched against the catalog). The footer links to it from every page, so collections are a first-class browse entry point rather than a search query. Unknown or malformed collection slugs return 404, never a 500. **Added:** *Collection browse pages* — `GET /collections` renders the active collections as cards (title, description, hero); `GET /collections/:slug` renders the collection's product grid, reusing the standard product card (image, title, price, link to the PDP). Public pages — no sign-in. · *Manual + smart resolution* — Manual collections list their hand-picked members; smart collections evaluate their stored rules against the active catalog and apply the collection's sort strategy. The page resolves each product fresh, so archived products drop out of the grid. · *Footer entry point* — The footer's Shop column links to `/collections` on every storefront page (edge- and container-rendered alike), making collections a real browse path. A bad or unknown slug is a 404.

- v0.0.127 (2026-05-24) — **Self-serve returns — customers request RMAs on their orders, operators approve and refund.** Signed-in customers can request a return against one of their own orders — pick the items and a reason at the order, then track status at /account/returns. Operators work the queue at /admin/returns: approve (with a refund amount), mark received, refund, or reject with a reason, following the pending → approved → received → refunded lifecycle. The customer request route loads the order and confirms it belongs to the signed-in customer before showing it, and builds the return lines from the order's own records, so a foreign or guessed order id returns 404 and a client can't return items it never bought. **Added:** *Customer return requests + status* — `/account/orders/:order_id/return` shows the order's items with a reason picker; `/account/returns` lists the customer's RMAs with status (pending / approved / received / refunded / rejected) and any rejection reason. The account dashboard links to it. Empty selection or a bad reason re-renders the form with the message. · *Operator return queue* — `GET /admin/returns?status=pending` lists the queue across all orders; `GET /admin/returns/:id` reads one; `POST /admin/returns/:id/approve` (with refund amount), `/received`, `/refund`, and `/reject` (with reason) walk the lifecycle. Bearer-token-gated; an illegal transition is a 409 and a bad id a 404, never a 500. · *`returns.listByStatus(status, opts)`* — Lists return authorizations across all orders by status, newest first, with the same opaque cursor as `listForCustomer`. Backs the operator queue.

- v0.0.126 (2026-05-24) — **Address book — saved shipping and billing addresses on the account page.** Signed-in customers can now keep a book of addresses at /account/addresses: add, edit, set a default shipping or billing address, and remove. Addresses are per-customer; every action that targets a specific address first confirms it belongs to the signed-in customer, so a guessed id can't read or change someone else's address. **Added:** *Address book at `/account/addresses`* — List, add, and edit saved addresses (recipient, company, two street lines, city, region, postal code, ISO country, phone). The account dashboard links to it. A blank product catalog isn't required — this is account surface only. · *Default shipping / billing* — Mark one address as the default shipping and one as the default billing; promoting a new default clears the previous one. Surfaced as badges on each card. · *Ownership-checked mutations* — Edit, update, set-default, and remove all resolve the address by id and verify it belongs to the signed-in customer before acting — a foreign or guessed id returns 404, never another customer's data. Add/edit re-render the form with the validation message on bad input.

- v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.

- v0.0.123 (2026-05-24) — **Wishlist — save products to your account, with social-proof counts on the product page.** Signed-in customers can now save products to a wishlist from the product page. A "N shoppers saved this" count surfaces social proof, and saved items live on a new account page where they can be removed or reopened. The save control and count render identically on the edge and container paths; the toggle is idempotent (saving twice is a no-op, toggling again removes) and login-gated, since a wishlist is scoped to one customer. **Added:** *Save to wishlist on the product page* — The PDP renders a "Save to wishlist" control and, once any customer has saved it, a "N shoppers saved this" social-proof count. Both render server-side on the edge and container paths. The count is public; the save action requires sign-in. · *`/account/wishlist` — saved items* — A new account page lists the customer's saved products with a thumbnail, a link back to the product, and a Remove control. Entries whose product was archived render as "no longer available" rather than breaking the list (wishlist rows are orphan-tolerant by design). The account dashboard links to it. · *`POST /wishlist/toggle`* — Login-required endpoint that saves the product if it isn't saved and removes it if it is. Idempotent (`INSERT OR IGNORE`). Redirects back to the product by resolving its canonical slug from the product id, or to a safe same-origin `return_to` (the account page's Remove uses it) — a forged or off-site redirect target is rejected.

- v0.0.122 (2026-05-24) — **Product reviews on the storefront — verified-buyer submission, operator moderation, and rich-snippet ratings.** The product page now shows customer reviews: an average rating, a per-star distribution, and the published review list, with `AggregateRating` structured data so star ratings can surface in search results. Submission is gated to signed-in customers who have actually purchased the product — the route confirms a completed order for that product before accepting a review, and re-checks on submit. Reviews land in a pending state; operators publish or reject them through new bearer-token admin endpoints. Display and structured data are identical whether the page is served at the edge or from the container. **Added:** *Reviews on the product page* — The PDP renders an average rating, a per-star distribution bar chart, and the published reviews (newest first, verified-buyer badge, date). Products with no reviews show an invite to be the first. Rendered server-side on both the edge and container paths. · *Verified-buyer review submission* — `GET /products/:slug/review` shows the review form to a signed-in customer who has purchased the product; everyone else is redirected to sign in or told only verified buyers can review. `POST /products/:slug/review` re-checks the purchase before accepting the review, so a direct POST can't bypass the gate. Submitted reviews start pending. · *Operator review moderation* — `GET /admin/reviews?status=pending` lists the moderation queue across all products; `GET /admin/reviews/:id` reads one; `POST /admin/reviews/:id/publish` and `POST /admin/reviews/:id/reject` move it. Bearer-token-gated like the rest of the admin API. Pending and rejected reviews never appear on the storefront. · *`AggregateRating` structured data* — The product page emits Schema.org `AggregateRating` (rating value + review count) nested in the existing `Product` JSON-LD when published reviews exist, so eligible products can show star ratings in search results. Omitted entirely at zero reviews to stay valid. The container render path now emits the full `Product` + `AggregateOffer` + `BreadcrumbList` JSON-LD that the edge already did. · *`order.hasPurchasedProduct(customerId, productId)`* — Existence check — true when the customer has an order line for any variant of the product in an order that reached `paid` or later (excludes `pending` and `cancelled`). Backs the review purchase gate. · *`reviews.listByStatus(status, opts)`* — Lists reviews across all products by status, newest first, with the same opaque tuple cursor as `listForProduct`. Backs the admin moderation queue.

- v0.0.121 (2026-05-24) — **Codebase-patterns detector blocks SHA3 primitives in `worker/` — prevents the regression that broke newsletter signup.** v0.0.120 fixed the newsletter signup by routing the POST to the container instead of computing `b.crypto.namespaceHash` (SHA3-512) at the edge — Workers' `nodejs_compat` doesn't expose SHA3-family digests. New `worker-uses-sha3-primitive` codebase-patterns detector flags any `b.crypto.{sha3Hash, hmacSha3, namespaceHash, shake256, shake512, hkdfSha3}(...)` call under `worker/` so the next operator can't re-introduce the substrate-mismatch bug. Catalog grows 121 → 122. Detector targets the exact SHA3-family primitive names rather than a general regex match — Worker code that legitimately uses `b.crypto.toBase64Url`, `b.crypto.timingSafeEqual`, `b.crypto.generateBytes`, `b.crypto.hmacSha256` (the Stripe webhook augment), etc. is unaffected. **Added:** *`worker-uses-sha3-primitive` detector* — Flags `b.crypto.sha3Hash(...)`, `hmacSha3(...)`, `namespaceHash(...)`, `shake256(...)`, `shake512(...)`, and `hkdfSha3(...)` in any file under `worker/`. Catches the substrate-mismatch class that broke v0.0.92's edge newsletter handler (live for 28 versions before being routed back to the container in v0.0.120). When edge code needs a stable hash: route to the container OR use `b.crypto.hmacSha256` IFF both sides read with the same SHA-256 path. Never have one substrate write SHA3 and another read SHA-256 — silent divergence breaks cross-substrate lookups (e.g. unsubscribe-by-email_hash).

- v0.0.120 (2026-05-24) — **Route POST /newsletter through the container — Workers `nodejs_compat` can't compute SHA3-512 (`b.crypto.namespaceHash`).** The v0.0.92 edge-served newsletter handler was 500-ing every submission with `Error: Digest method not supported`. Root cause surfaced via the temporary `X-Newsletter-Diag` header (added in v0.0.119): Cloudflare Workers' `nodejs_compat` runtime exposes `node:crypto` but the supported digest set is a subset of full Node — `createHash("sha3-512")` isn't in it. Working around with a Web-Crypto-API SHA-256 fallback would silently diverge the Worker's `email_hash` values from the container's SHA3-512 values, breaking the unsubscribe lookup (different hash → different row → silent unsubscribe failure). The edge handler was the wrong substrate for this primitive. The POST falls through to `_forwardToContainer` so the framework's SHA3-512 path runs server-side and the `email_hash` column stays consistent across reads. The ~200ms container hop on signup submit is paid back by a working unsubscribe flow. The dead `_edgeNewsletter` function + its `renderNewsletterThanks` / `renderNewsletterError` imports + the diagnostic `X-Newsletter-Diag` header are all removed in the same patch. **Fixed:** *Newsletter signup now uses the framework's SHA3-512 hash via the container* — Removed: `_edgeNewsletter` handler (123 lines), `renderNewsletterThanks` / `renderNewsletterError` imports in `worker/index.js`, the `X-Newsletter-Diag` diagnostic response header (its job is done). The dispatch comment above the now-removed `if (pathname === "/newsletter" && ...)` block documents the substrate decision + the SHA3-512 / Workers-runtime constraint so the next operator doesn't re-introduce the edge handler.

- v0.0.119 (2026-05-24) — **Newsletter handler surfaces `X-Newsletter-Diag` header so the live 500 root cause is visible without wrangler tail.** v0.0.118 added missing-table resilience to the newsletter handler, but every submission still 500s — the underlying D1 error isn't matching the `"no such table" / "no such column"` resilience-trigger regex. Without wrangler tail access during the diagnostic loop, the actual error message stays invisible. This patch adds an `X-Newsletter-Diag: <ErrorClass>: <first 96 chars of message>` response header (redacted through `_redact` so secrets never leak) on the 500 path. The body remains the canonical operator-facing error page; the header surfaces the failing step. Operator probes `curl -sI` and reads the diag value to pinpoint the live root cause (likely a D1 binding shape mismatch / migration gap / module-load issue), then ships a targeted fix in the next patch. The diag header is intentionally short-lived — removable once the cause is identified and permanently fixed. **Added:** *`X-Newsletter-Diag` response header on the 500 path* — `_edgeNewsletter`'s outer catch now sets `X-Newsletter-Diag: <constructor.name>: <message[:96]>` (redacted through the framework's `b.redact.redact` pipeline). The body still renders the canonical 500 page; the header gives the operator a diagnostic surface without needing wrangler tail. Removable in a follow-up once the live root cause is identified and the underlying fix lands.

- v0.0.118 (2026-05-24) — **Newsletter signup — missing-table resilience + per-step error attribution.** POST `/newsletter` was returning a generic 500 for every submission — valid email, invalid email, empty form, every shape. The outer try/catch was swallowing whatever the D1 INSERT threw and falling into `renderInternalError`. Sub-step attribution wasn't logged, so wrangler tail saw `edge POST /newsletter failed:` with a stack but the visitor saw the 500 page. The most likely root cause: migration `0010_newsletter_signups.sql` hasn't been applied to live D1 yet, so the INSERT throws `no such table: newsletter_signups`. This patch wraps the INSERT in its own try/catch, logs the underlying D1 message via `console.error`, and returns a clean 503 `"Newsletter signup temporarily unavailable"` with `Retry-After: 60` when the table (or a column) is missing. Unknown D1 failures (constraint conflicts, network errors) still escalate to the outer catch and the existing 500 page. The visitor gets a clear retry signal instead of a generic error; the operator sees a specific signal that migrations need to be applied. **Fixed:** *Newsletter handler degrades gracefully when D1 schema is missing* — `worker/index.js#_edgeNewsletter` wraps the INSERT in a per-step try/catch. On `"no such table"` / `"no such column"` errors, returns `503 Newsletter signup temporarily unavailable.` with `Retry-After: 60` + the framework's security headers. Other D1 failures (constraint conflicts, network errors, etc.) re-throw to the outer catch — the existing 500 page renders, the underlying message lands in `console.error` → wrangler tail / Logpush. Mirrors the missing-table-resilience pattern shipped for blog queries in v0.0.109.

- v0.0.117 (2026-05-24) — **Fix Add to Cart — `container.fetch` was auto-following 303 redirects and dropping `Set-Cookie`.** Add to Cart appeared to do nothing for visitors: the form POST returned 200 with the empty-cart page instead of the expected 303 + Set-Cookie + Location: /cart. Root cause: `_forwardToContainer` constructed the container-side Request with the Workers default `redirect: "follow"`, which auto-follows the storefront's 303 inside the Worker. The auto-followed GET /cart strips the `Set-Cookie: shop_sid=...` header from the intermediate 303 response — so the session cookie never reaches the visitor's browser, the subsequent (visible to the visitor) /cart GET arrives without the session, and the cart-line lookup keys on a fresh empty cart. End result for the visitor: the line WAS persisted to the container's session store, but every subsequent page load creates a new sessionless cart and shows it empty. Fix: `_attemptRequest` constructs the container-side Request with `redirect: "manual"` so the 303 + Set-Cookie chain passes through verbatim to the visitor's browser, which carries the cookie forward through the natural redirect navigation. Same bug affected `/cart/lines/<id>/update`, `/cart/lines/<id>/remove`, `/newsletter`, and any other server-side 303-with-cookie pattern routed through the container — all of those are fixed by the single line change. **Fixed:** *Cart-mutation 303 redirects preserve their `Set-Cookie` end-to-end* — `worker/index.js#_forwardToContainer` now constructs the container-side Request with `redirect: "manual"`. The storefront's POST handlers (`cart/lines`, `cart/lines/<id>/update`, `cart/lines/<id>/remove`, `newsletter`, future checkout submits) all reply with 303 + `Location` + `Set-Cookie: shop_sid=...` on the same response. Manual redirect handling passes that triple verbatim to the visitor's browser, which carries the cookie through the redirect navigation. Add to Cart now persists across the redirect; subsequent /cart loads show the line; the visitor sees what they expected to see when they clicked the button.

- v0.0.116 (2026-05-24) — **Top-level Worker exception catcher — convert Cloudflare 1101 to a clean 503 warming page.** An uncaught exception in the Worker's fetch handler used to propagate to the Cloudflare runtime, which served the generic `Error 1101 — Worker threw exception` page (cf-branded interstitial referencing wrangler tail). Visitors saw a Cloudflare error instead of the framework's render. Every fetch invocation now runs inside `_withTopLevelCatch`: catches every escape, logs to `console.error` (so wrangler tail / Logpush still see the underlying cause), and serves the existing 503 warming-up page with the framework's security headers + a `Retry-After: 10` hint. Cold-start blips during deploys, sporadic D1 / R2 binding hiccups, and any other exception that would have surfaced as 1101 now serve a branded, refresh-aware page that respects the operator's CSP / Permissions-Policy / HSTS posture. **Added:** *Top-level Worker exception catcher (`_withTopLevelCatch`)* — Wraps every `fetch` invocation. On exception: logs the stack via `console.error` (Worker substrate's observability sink — surfaces in `wrangler tail` + Logpush), then returns 503 with the existing `_warmingHtml(request.url, 8)` page + the framework's full security-headers set + `Retry-After: 10` + `Cache-Control: no-store`. Cloudflare's generic 1101 page never reaches the visitor; the cf-managed `Please enable cookies` interstitial only fires when CF's edge layer (Bot Fight Mode / challenge platform) intervenes BEFORE the Worker runs, not from Worker-thrown exceptions.

- v0.0.115 (2026-05-23) — **Drop `crossorigin` from the CSS preload Link header — fixes browser preload-credentials-mismatch warning.** Browsers were warning `A preload for '/assets/themes/default/css/main.css?v=0.0.0' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.` Root cause: the Early Hints / `Link: rel=preload` header carried `crossorigin`, but the matching `<link rel="stylesheet">` tag in the rendered HTML did not. The browser treats the preload as a CORS request, the stylesheet as a same-origin request, and the two don't share a connection — the preloaded byte stream is discarded and the stylesheet is re-fetched from scratch. The asset is genuinely same-origin (`/assets/...`), so `crossorigin` was always wrong on the preload side; removing it lets the preload and stylesheet share the request credentials mode and the browser reuses the preloaded bytes. **Fixed:** *`_earlyHintsLink` emits the CSS preload without `crossorigin`* — `worker/index.js#_earlyHintsLink` now emits `</assets/themes/default/css/main.css?v=...>; rel=preload; as=style` — no `crossorigin` token. The asset is same-origin, the stylesheet `<link>` tag carries no `crossorigin` attribute, and the browser's preload-credentials-match check passes. Devtools no longer warns `request credentials mode does not match`. Operators with per-route extras (hero image preload on PDP, etc.) inherit the same shape — extras already shipped without `crossorigin`.

- v0.0.114 (2026-05-23) — **Catalog mirror — close out the last 7 detectors + `matchOn: "basename"` runner support.** Closes the codebase-patterns catalog gap against vendored blamejs. The seven detectors held back from the v0.0.113 bulk port — six because the splice tool couldn't roundtrip their regex literals (embedded double-quotes / complex character classes) and one (`release-named-test-file`) that needed `matchOn: "basename"` support in the runner — are now hand-ported. Shop's catalog grows from 114 to 121 detectors, with seven covering archive-wrap PQC hybrid contracts, SQL transaction-wrapper extraction, the two `Map.prototype.getOrInsertComputed` Node-26 migration variants, PQC AlgorithmIdentifier `NULL` parameters anti-pattern, release-named test-file refusal, and `safeDecompress` paired-cap discipline. One legitimate `map-has-then-set` site in `lib/analytics.js#byCurrency` surfaced through the port and is kept with an inline `allow:` marker documenting the Node-26 floor-bump migration target. **Added:** *Seven new codebase-patterns detectors complete the catalog mirror* — `archive-wrap-recipient-missing-ec-half` (hybrid PQC `recipient: { publicKey: ... }` must also carry `ecPublicKey:` — uses `requires:` companion-check to exempt files where the matching `ecPublicKey` lives elsewhere), `inline-sql-transaction-wrapper` (BEGIN/COMMIT/ROLLBACK try/catch boilerplate routes through `dbSchema.runInTransaction`), `map-get-or-insert-pre-node-26` + `map-has-then-set-pre-node-26` (both variants of the two-step Map insert-if-absent pattern that Node 26 collapses to `Map.prototype.getOrInsertComputed`), `pqc-algid-with-null-params` (RFC 9909/9881/9936 — PQC AlgorithmIdentifier parameters field is ABSENT, not `NULL`), `release-named-test-file` (refuses `v0-8-41-additions.test.js` / `slot-19-enhancements.test.js` / `batch-N.test.js` shapes — uses the new `matchOn: "basename"` runner feature), `safedecompress-omits-max-compressed-bytes` (every `safeDecompress({ maxOutputBytes })` call MUST also name `maxCompressedBytes` so the caps stay aligned with operator intent — uses `requires:` companion-check). · *`matchOn: "basename"` runner support* — `_scan(regex, scope, { matchOn: "basename" })` matches the regex against each file's basename rather than file contents. Used by detectors that police naming conventions (e.g. release-named-test-file). The feature mirrors the runner contract in vendored blamejs's catalog so future basename-policing detectors can be ported verbatim. **Changed:** *`lib/analytics.js#byCurrency` carries an inline `allow:map-has-then-set-pre-node-26` marker* — The `if (!byCurrency.has(cur)) { ...byCurrency.set(cur, {...}); }` insert-if-absent in the per-currency aggregate loop migrates cleanly to `Map.prototype.getOrInsertComputed` when the framework's `engines.node` floor bumps to Node 26 (eligible Oct 2026 per the LTS calendar). The detector now exists so a fresh occurrence post-this-patch trips pre-merge; the existing site is allowed with a reason that includes the migration target.

- v0.0.113 (2026-05-23) — **Catalog mirror — 66 new codebase-patterns detectors ported from vendored blamejs.** The codebase-patterns detector catalog grows from 48 to 114 — 66 new detectors ported verbatim from `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. The full vendored set covers archive-handling traps, framework-helper composition (the `inline-*` family), Node-version-conditional patterns, SBOM derivation traps, mail-server TLS upgrade hygiene, mountinfo bind-detection invariants, and dozens of other bug classes the framework has surfaced through audits. Every new detector scopes `shop` (lib + worker) so reinventions are caught anywhere in the application surface. Vendor allowlist entries referencing internal blamejs paths (e.g. `lib/crypto.js`) are kept verbatim — shop's `_walk` skips the vendor tree, so those entries are harmless no-ops and preserve the operator-readable reason text. Three pre-existing inline reinventions surfaced through the port and are kept with inline `allow:` markers documenting why the framework helper can't compose — the existing `TypeError` message contract requires the literal field name (`/timeoutMs/`, `/opts.version/`) which `validateOpts.*`'s `code`-first error shape doesn't carry. Eight detector blocks (six with regex literals containing literal `"` characters that the splice tool can't roundtrip, plus `release-named-test-file` whose `matchOn: "basename"` directive shop's runner doesn't honor yet, plus `inline-base64url-three-replace` already ported in 0.0.112) defer to a follow-up. **Added:** *Mirror 66 new codebase-patterns detectors from vendored blamejs* — Categories: archive-handling (extract-overwrite-without-refusal, gz-without-safedecompress, read-gz-without-self-authored-budget, tar-walker-without-truncation-check, wrap-recipient-missing-ec-half — except wrap-recipient which deferred to follow-up); the `inline-*` family of framework-helper composition reminders (40+ detectors covering aggregate-issues, assert-no-char-threats, audit-emit-wrapper, audit-shape-validation, bad-input-issue-result, batch-positive-int-validation, buffer-byte-equality-loop, build-guard-gate-forwarder, char-strip-policy-cascade, codepoint-class-table, compliance-posture-lookup, crlf-string-test, default-resolution-cascade, detect-char-threats, emit-event-wrapper, extract-bytes-as-text, flush-timer-scheduler, hex-string-validator, iso8601-millisecond-strip, issue-validator-entry, log-via-or-fallback, migration-filename-regex, numeric-bounds-cascade, object-store-http-request, observability-shape-validation, optional-* validators, profile-builder-forwarder, redis-client-opts-forwarding, require-* validators, resolve-profile-and-posture, rule-pack-loader, sql-identifier-regex, trailing-hspace-strip); SBOM derivation traps (toplevel-ref-by-slash-heuristic, bom-ref enum-rank-without-validation, etc.); compliance/posture coverage (compliance-posture-coverage-drift); cred-store + mailstore + mountinfo invariants; PQC + dot-stuff regex shapes; CONDSTORE + BDAT mail-server invariants. Full list visible by diffing `test/layer-0-primitives/codebase-patterns.test.js` against the v0.0.112 baseline. · *Worker `b` adapter exposes `b.validateOpts`* — `worker/b.js` now re-exports the `validate-opts` leaf module so future Worker primitives can compose `b.validateOpts.optionalPositiveFinite` / `b.validateOpts.optionalFunction` / `b.validateOpts.requireNonEmptyString` etc. against the same one-source-of-truth as lib-side primitives. Adds the leaf to the Worker bundle but doesn't bind a public namespace change. **Changed:** *Three inline-validation sites carry per-line `allow:` markers* — `lib/externaldb-d1.js#_validateOpts` (two lines: `timeoutMs`, `fetch`), `lib/r2-bridge.js#_validateOpts` (one line: `timeoutMs`), and `worker/render/search.js#renderSearch` (one line: `opts.version`) keep the literal `TypeError` shape with the labelled message because each is paired with a test contract that asserts the field name appears in the message (`assert.throws(..., /timeoutMs/)`). The framework's `validateOpts.*` helper produces a `code`-first `TypeError` (`TypeError: validate-opts/bad-positive-finite`) whose message doesn't carry the label; routing through it would silently strip the labelled-field contract from the test. Marker explains the constraint at the source.

- v0.0.112 (2026-05-23) — **Compose `b.crypto.toBase64Url` instead of reinventing — nine primitives + new detector.** Nine framework primitives (`api-keys`, `carrier-accounts`, `customer-surveys`, `stock-alerts`, `stock-receipts`, `subscription-gifts`, `webhook-receiver`, `wishlist-sharing`, `storefront`'s server-side `_b64u`) shipped with hand-rolled base64url encoding — the canonical four-line `.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")` chain. The trailing-padding `.replace(/=+$/, "")` strip is polynomial-ReDoS-shaped per CodeQL js/polynomial-redos. The framework's `b.crypto.toBase64Url(buf)` helper routes through Node's built-in `base64url` encoding which is linear-time and produces the same RFC 4648 §5 output. Every server-side callsite now composes the framework primitive. Two browser-side helpers in `lib/storefront.js`'s WebAuthn UI string-template (no `b.crypto` available in the page runtime) carry inline `allow:inline-base64url-three-replace` markers — `window.btoa` plus the three-replace shim is the browser-built-in equivalent. New `inline-base64url-three-replace` codebase-patterns detector enforces the composition shop-wide. **Added:** *`inline-base64url-three-replace` codebase-patterns detector* — Flags `.replace(/=+$/, ...)` anywhere under `lib/` or `worker/`. Catches the canonical four-line base64url reinvention chain at the trailing-padding-strip line. Ported verbatim (with shop-scope) from blamejs's catalog. Browser-side string-template emissions in `lib/storefront.js` carry inline allow markers documenting that the browser has no `b.crypto` runtime — `window.btoa` plus the three-replace shim is the built-in. **Changed:** *Nine primitives compose `b.crypto.toBase64Url`* — `api-keys` `_generateToken`, `carrier-accounts` `_generateSecret`, `customer-surveys` `_generateToken`, `stock-alerts` `_mintToken` (inlined the redundant `_b64url` helper), `stock-receipts` `_generateToken`, `subscription-gifts` `_generateToken`, `webhook-receiver` `_generateSecret`, `wishlist-sharing` `_generateToken`, and `storefront`'s server-side `_b64u` helper all now route through `_b().crypto.toBase64Url(buf)`. Linear-time output, no polynomial-ReDoS surface, no Node-minor-version Buffer-flag-rename risk.

- v0.0.111 (2026-05-23) — **Restore /feed.xml + /sitemap.xml — `b.safeUrl.parse` returns a URL instance, not a string.** `/feed.xml` and `/sitemap.xml` have been responding 503 "temporarily unavailable" since they shipped. Root cause: three edge handlers (sitemap, feed, scheduled cache-warmer) chained `.replace(/\/$/, "")` directly off `b.safeUrl.parse(...)`. The framework primitive returns a WHATWG `URL` instance, not a string — `URL.prototype.replace` doesn't exist, so every request threw `TypeError: ... .replace is not a function`. The handler's catch swallowed the throw and returned the canonical 503, making the routes appear transiently unavailable when they were in fact permanently broken on that callsite. The bug went undetected because production deploys were frozen at v0.0.101 for a separate reason (now fixed in v0.0.110), so the broken handlers never reached live traffic until today. Fix: chain `.href.replace(/\/$/, "")` so the string method operates on the URL's href string. A new `safeurl-parse-string-method` detector catches the shape repo-wide (47 detectors → adds 1, total 47 → 48). **Added:** *`safeurl-parse-string-method` codebase-patterns detector* — Flags `b.safeUrl.parse(...).<strMethod>(` anywhere under `worker/` — covers `replace` / `startsWith` / `endsWith` / `split` / `includes` / `slice` / `indexOf` / `toLowerCase` / `toUpperCase`. Property access (`.href`) is intentionally NOT flagged — that's the correct shape. The detector exists because three handlers shipped this anti-pattern without anyone noticing while production was frozen; pre-merge detection prevents the next instance from regressing through review the same way. **Fixed:** *Restore `/feed.xml`, `/sitemap.xml`, and the scheduled cache-warmer* — Three callsites in `worker/index.js` shipped `b.safeUrl.parse(env.D1_BRIDGE_URL || "https://blamejs.shop").replace(/\/$/, "")` — `URL.prototype.replace` doesn't exist, so the worker threw TypeError on every request and the edge handler's catch returned the canonical 503. Replaced with `.href.replace(...)` so the trailing-slash strip runs against the URL's serialized form. The semantic contract (operator-configured origin with no trailing slash) is preserved across operator inputs with or without a path prefix.

- v0.0.110 (2026-05-23) — **Unblock Cloudflare production deploys + vendor blamejs v0.12.11 + vendor-drift demoted to warning.** Production Cloudflare Workers deploys have been failing on every push since v0.0.102. Root cause: the container image excludes `worker/` via `.dockerignore` (the worker is deployed separately by `wrangler deploy`, the container only ships the long-running backend), but `test/smoke.js` runs inside the container build's `RUN node test/smoke.js` step and calls the `worker-syntax` gate, which tries to read `worker/index.js` and crashes with `ENOENT`. The Docker build exits 1, `wrangler deploy` aborts, and the production worker is left frozen at the last successful build. The fix: `scripts/check-worker-syntax.js` now skips with a no-op stderr message and exits 0 when its entry file isn't present in the current build context — outside the container (host smoke / CI / npm-publish) the entry is always present and the strict scan runs unchanged. Vendor refresh: blamejs v0.12.11 (carries upstream's matching disciplines). Vendor-drift gate demoted from failure to warning: drift surfaces on stderr (still visible in CI logs + operator terminal) but no longer blocks `npm test` — the committed vendor tree is the source of truth, and operators don't need to refresh on every blamejs release before they can ship an unrelated patch. **Changed:** *`vendor-update.sh --check` warns instead of failing on drift* — Drift between vendored blamejs and the latest upstream tag now surfaces on stderr as `[vendor-check] WARNING — vendored vA.B.C, latest vX.Y.Z` and exits 0. The committed vendor tree is the source of truth — there's no integrity loss from an older-but-still-supported vendor version, and forcing a refresh on every minor blamejs release before any unrelated patch could ship was friction without a corresponding safety win. CI logs and the operator's terminal still display the warning prominently. · *Vendor refresh: blamejs v0.12.10 → v0.12.11* — Standard shallow-clone refresh via `scripts/vendor-update.sh blamejs v0.12.11`. No code changes outside `lib/vendor/blamejs/`. **Fixed:** *Worker-syntax gate skips gracefully when `worker/index.js` is absent* — `scripts/check-worker-syntax.js` now checks for the existence of its entry file before scanning. When the file is missing (Cloudflare container build context, where `worker/` is excluded by `.dockerignore`), the gate logs `[worker-syntax] SKIPPED — <entry> not present in this build context (no worker tree to scan)` to stderr and exits 0. When the file IS present (host smoke runs, CI runners, npm-publish workflow, edge-render checks) the strict balance-walker scan runs unchanged and a missing trailing `}` still fails the build. Unblocks production deploys that have been failing since v0.0.102.

- v0.0.109 (2026-05-23) — **Blog-table-resilient D1 queries + tighter `edge-handler-catch-returns-null` regex.** Live probing surfaced that `/feed.xml` (503), `/sitemap.xml` (503), and `/blog` (500) all fail when the live D1 doesn't have the `blog_articles` table — migration `0189` ships with the blogArticles primitive (v0.0.75) but hadn't been applied to the live deployment yet. The four blog-touching queries in `worker/data/catalog.js` now treat `"no such table"` errors as "no published articles" and return empty rows, so the dependent edge routes degrade gracefully (feed renders no items, sitemap renders only the product + static URLs, /blog shows the empty-state). The `edge-handler-catch-returns-null` detector also tightens — the previous regex spanned 400 chars after the catch's opening `{`, which reached across function boundaries into the next function's guard-clause `return null;` and tripped a false positive. The new regex uses `[^}]` to stay inside the catch body. **Fixed:** *Blog-table-resilient queries in `worker/data/catalog.js`* — `listPublishedBlogSlugs`, `listBlogArticles`, `getBlogArticleBySlug`, and `recentBlogArticles` now catch `"no such table"` errors from D1 and return empty rows / null. Operators who haven't applied migration `0189_blog_articles.sql` (or who deploy the worker before the migrations land) get a clean degradation instead of `/feed.xml` 503 / `/sitemap.xml` 503 / `/blog` 500. Non-table-missing errors still propagate to the caller's `_edgeError` for the canonical 5xx render. · *`edge-handler-catch-returns-null` regex tightened — no more cross-function false positives* — Previous regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[\s\S]{0,400}?return\s+null\s*;` — the `[\s\S]` matches anything including the catch's closing `}`, so a small catch body followed shortly by a function-guard `return null;` was matching as if the null were inside the catch. New regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[^}]{0,400}return\s+null\s*;` — `[^}]` keeps the match inside the catch body. Catches still flagged for injection-tested anti-pattern (`catch (e) { return null; }`); the blog-query data layer's guard-clause `return null` outside any catch is no longer flagged. · *Blog-query refactor — null-return moves outside the catch body* — `getBlogArticleBySlug` now uses a `var row = null` outer binding and assigns inside the try; the catch swallows the missing-table case without an inner `return null`. The function's contract ("returns the row or null when not found") is preserved; the tightened detector regex doesn't flag the new shape.

- v0.0.108 (2026-05-23) — **ETag + `If-None-Match` 304 handling on `/privacy` + `/terms` — saves the body bytes on every browser revalidate.** `/privacy` and `/terms` carry `max-age=3600` so a returning visitor's browser revalidates the resource every hour. Previously the revalidate downloaded the full body each time (~5KB); now the response carries an `ETag` derived from a FNV-1a hash of the minified body, and the handler compares incoming `If-None-Match` against the same etag — on a match it returns `304 Not Modified` with no body. The Edge cache (24h s-maxage) holds the 200 response; revalidates hit the Worker, run the cheap hash, and 99% of the time return a few hundred bytes instead of the full page. FNV-1a is chosen for speed + zero allocation — collision irrelevant because operators (not visitors) control the policy text, so the attacker-controlled input space at this surface is empty. **Added:** *ETag + `If-None-Match` 304 handling in `_staticHtml`* — `_staticHtml(body, method, env, request)` minifies the body, computes a 32-bit FNV-1a hash of the minified bytes, formats as a quoted hex ETag (`"<hash>"`), and returns 304 (with the ETag header repeated per RFC 7232 §4.1) when the incoming `If-None-Match` matches. The hash is content-stable — a re-render of the same operator-set policy text produces the same ETag across edge instances. Carries through to `/privacy` and `/terms`; the dynamic edge-rendered routes (`/`, `/search`, `/products/:slug`, `/blog`, `/blog/:slug`) keep their `no-store` posture since their D1-sourced content changes per-request.

- v0.0.107 (2026-05-23) — **`X-Robots-Tag: noindex, nofollow` on `/cart` — crawlers stop indexing the session-bound empty-state.** The guest `/cart` route ships an empty-cart render at the edge for visitors without a session cookie. Crawlers reaching the page were indexing it as if it were content; the empty-state snippet ("Your cart is empty") was appearing in SERPs as a stale search result for unrelated queries. The handler now sets `X-Robots-Tag: noindex, nofollow` on the response so well-behaved crawlers skip indexing. `robots.txt` already `Disallow`s the route — the response header is belt-and-suspenders. **Changed:** *`_edgeCartEmpty` sets `X-Robots-Tag: noindex, nofollow`* — The handler now builds its own Response (instead of routing through `_html`) so the response carries the X-Robots-Tag header alongside the full security-header set + Link preload + minification. Per-route headers compose cleanly without changing `_html`'s signature for one outlier.

- v0.0.106 (2026-05-23) — **RFC 9116 `/.well-known/security.txt` served from the edge.** Vulnerability researchers, automated scanners, and bug-bounty platforms look for `/.well-known/security.txt` (RFC 9116) before opening an unsolicited email to a generic address. The Worker now serves the canonical text with `Contact`, `Expires`, `Encryption`, `Policy`, and `Preferred-Languages` fields. Contact points at `security@blamejs.shop` and the GitHub Security Advisories private-disclosure URL; Policy points at the repo's `SECURITY.md`. Cached `public, max-age=3600, s-maxage=86400`. **Added:** *`/.well-known/security.txt` — RFC 9116 vulnerability-disclosure metadata* — The Worker emits the canonical text with five fields. `Contact: mailto:security@blamejs.shop` + `Contact: https://github.com/blamejs/blamejs.shop/security/advisories/new` (private-disclosure path through GitHub Security Advisories — researchers don't have to email to start a disclosure). `Expires:` is set to one year from response time so crawlers don't refetch constantly. `Policy: https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` points at the in-repo disclosure policy. `Preferred-Languages: en`. Every line ends `\r\n` per the RFC. Carries the full security-header set + 1h browser / 24h edge cache.

- v0.0.105 (2026-05-23) — **Per-route Link preloads — PDP carries its hero image in the HTTP/103 Early Hints frame.** `_html(body, method, env, preloads)` now accepts an extra `preloads` array of `{ href, as }` entries — Worker render handlers can append per-route preload hints alongside the always-present theme-stylesheet preload introduced in v0.0.103. `_edgeProduct` uses the new shape to pass the first media row's image URL as `rel=preload; as=image`, so Cloudflare's HTTP/103 Early Hints frame on a PDP carries both the stylesheet AND the hero image. The browser starts both fetches before the HTML body finishes streaming — cuts product-page LCP by another ~100-200ms on a typical render with a large hero. **Added:** *`_html` accepts per-route preloads via a 4th `preloads` array argument* — `_earlyHintsLink(env, extras)` merges the always-on theme-stylesheet preload with any caller-supplied `{ href, as }` entries into a single comma-separated Link header. Cloudflare's edge promotes the full header to multiple HTTP/103 Early Hints frame entries. Per-route preloads cost zero render time (already part of the response headers); the browser-side fetch starts during the round-trip the HTML body is using. · *`_edgeProduct` preloads the first media row's hero image* — The PDP's LCP element is almost always the hero image. The handler now appends `{ href: "/assets/<r2_key>", as: "image" }` to the `_html` preloads when the product has media; products without media render the placeholder letter-mark without a network fetch (no preload needed). Combined with the v0.0.103 theme-stylesheet preload, the HTTP/103 frame on a PDP carries both critical-path assets.

- v0.0.104 (2026-05-23) — **Organization + WebSite + BreadcrumbList Schema.org JSON-LD — three more rich-result types covered.** Home page now emits Organization (Google knowledge-panel + logo + social link) and WebSite (sitelinks search box pointing at `/search?q=`) JSON-LD blocks. Product detail page gains a BreadcrumbList block matching the on-page `<nav class="breadcrumb">` markup. All three compose through the existing `jsonLdScript` helper from `worker/render/_lib.js`. Combined with the Product + Article blocks from v0.0.94, the storefront now ships five distinct Schema.org graph types — Product / Article / Organization / WebSite / BreadcrumbList — across the routes Google's rich-result tester maps. **Added:** *Organization + WebSite JSON-LD on `/`* — `worker/render/home.js#renderHome` appends two `<script type="application/ld+json">` blocks to the body. Organization: `name` + `url` + `logo` + `sameAs` (GitHub mirror) — surfaces in Google's knowledge-panel result. WebSite: `potentialAction` with a `SearchAction` template at `/search?q={search_term_string}` so Google's sitelinks search box hands queries directly to the storefront's search route. · *BreadcrumbList JSON-LD on `/products/:slug`* — `worker/render/product.js#renderProduct` appends a BreadcrumbList block alongside the existing Product block. Two positions: `Shop` (`/`) → `<product title>` (`/products/<slug>`) — matches the on-page breadcrumb nav. Google's product-rich-result panel renders this above the title.

- v0.0.103 (2026-05-23) — **HTTP/103 Early Hints `Link: rel=preload` on every HTML response + vendor v0.12.10.** Every edge-rendered HTML response now carries `Link: </assets/themes/default/css/main.css?v=<version>>; rel=preload; as=style; crossorigin` in the headers. Cloudflare auto-promotes Link headers on 200 responses to HTTP/103 Early Hints frames — repeat visitors see the stylesheet fetch start before the HTML body arrives, accelerating First Contentful Paint and Largest Contentful Paint. The single preload covers the theme bundle every page references; per-route preloads (hero images, etc.) compose by appending to the same header. Bundles vendor refresh from blamejs v0.12.9 to v0.12.10. **Added:** *`Link: rel=preload; as=style` on every HTML response — HTTP/103 Early Hints eligibility* — `worker/index.js` exposes `_earlyHintsLink(env)` returning the canonical preload header for the theme stylesheet (`/assets/themes/default/css/main.css?v=<WORKER_VERSION>`). `_html(body, method, env)` and `_staticHtml(body, method, env)` both inject the header into every 200 / static HTML response. Cloudflare's edge auto-promotes Link headers on 200 responses to HTTP/103 frames — repeat visitors and clients with HTTP/3 see the stylesheet preload start before the HTML body finishes streaming, cutting LCP by ~50-150ms on a typical render. Per-route preloads (e.g. hero image on a product page) compose by appending an additional Link entry. **Changed:** *Vendored blamejs refreshed from v0.12.9 to v0.12.10* — `bash scripts/vendor-update.sh blamejs v0.12.10` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.9 and v0.12.10.

- v0.0.102 (2026-05-23) — **`_redact` composes `b.redact.redact` after the shop-specific patterns — defense-in-depth on log emission.** The Worker's `_redact` helper carried a hand-rolled chain of regex replacements covering shop-specific log shapes (Authorization Bearer / Stripe-Signature / shop_sid / x-d1-bridge-secret / hex digests / opaque tokens). The framework's `b.redact.redact` covers a different set — PEM blocks, AWS access keys, credit-card numbers, SSNs, connection strings, well-known credential field-names — that the shop chain doesn't enumerate. This patch keeps the shop chain (it catches header / cookie shapes b.redact misses) and composes `b.redact.redact` on top so the framework's catalog of sensitive value shapes also runs over the output. Log emissions through `_redact(...)` are now covered by both layers. **Changed:** *`_redact` chains `b.redact.redact` after the shop-specific patterns* — Order: (1) shop chain — Bearer / Stripe-Signature / JWT-tri-segment / shop_sid+shop_auth cookies / d1_bridge_secret + admin_api_key + stripe_*_key headers / 32-char hex / 40-char opaque token; (2) `b.redact.redact` on the result — picks up PEM `-----BEGIN`, AWS `AKIA*`, credit-card Luhn, SSN, JWT (stricter shape), connection strings, and the framework's sensitive-field-name catalog. Both run on every Worker `console.error` / observability emission that flows through `_redact`. `b.redact` joins the Worker-side primitive surface via `worker/b.js` alongside the v0.0.100 `b.cookies` addition.

- v0.0.101 (2026-05-23) — **`worker-syntax` smoke gate (network-free brace/paren/bracket walker) — replaces the broken esbuild gate.** v0.0.99's `worker-esbuild-parse` gate needed `npx esbuild` and the Cloudflare Workers Builds container test stage has no network; v0.0.100 had to remove it. This patch ships `scripts/check-worker-syntax.js` — a self-contained Node walker that reads every reachable file under `worker/` tracking string / template / comment / regex-literal state and asserts balanced braces / parens / brackets at EOF. Catches the node-tolerates / esbuild-refuses class that broke v0.0.95 + v0.0.97 (missing trailing `}` on a function declaration). Runs without an npm-registry hit, runs inside the container build stage without network access, runs in ~50ms. Verified via injection — appending `function _badInject() {` to `worker/index.js` trips the gate; restoration clears it. **Added:** *`scripts/check-worker-syntax.js` + `worker-syntax` smoke gate* — Walks `worker/index.js` and every locally-imported worker module (`./*.js` / `../*.js` references resolved to files under `worker/`) one-deep — at the time of writing that's 10 files. Per-file state machine tracks single-quoted strings, double-quoted strings, template literals (with `\` escape handling), line + block comments, and regex literals (including `[ ]` char classes that contain a literal `/`). At EOF, asserts `{ } ( ) [ ]` are all balanced; an unclosed string / template / comment also fails. On imbalance, the failure message names the file + line of the last-opened token that didn't close. · *Wired into `test/smoke.js` as the fourth static gate* — Runs alongside `release-notes-rollup` / `changelog-in-sync` / `vendor-drift`. Verified by injection — `function _badInject() {` appended to `worker/index.js` produces `[worker-syntax] FAIL — worker/index.js:<line> — imbalance braces=1 parens=0 brackets=0 — opening \`{\` at line <N> not closed` and exits smoke non-zero; restoring the file clears the gate.

- v0.0.100 (2026-05-23) — **Remove the broken `worker-esbuild-parse` gate (v0.0.99 deploy blocker) + compose `b.cookies` for the session-cookie presence check.** v0.0.99's `worker-esbuild-parse` smoke gate ran `npx esbuild` inside the container's Dockerfile test stage. The test stage doesn't carry `node_modules` and the build environment has no network access at that point, so `npx esbuild` couldn't download esbuild and exited non-zero — breaking the smoke gate, breaking the auto-deploy. Gate removed. The original failure mode the gate was guarding against (a missing closing brace that Node tolerates but esbuild refuses) still surfaces in the Cloudflare Workers Builds run itself; a redesigned gate that doesn't need a network-reachable npm registry will land in a follow-up. Same patch composes `b.cookies.parseSafe` for the `_hasSessionCookie` presence check (replaces the inline regex with the framework's parser + grammar enforcement). **Changed:** *`_hasSessionCookie` composes `b.cookies.parseSafe` instead of a regex presence check* — Previous shape: `/\b(shop_sid|shop_auth)=/.test(cookieHeader)` — close enough for presence but skipped the framework's RFC 6265bis token-grammar enforcement (length cap, duplicate-key resolution, CRLF refusal). New shape: `b.cookies.parseSafe(cookieHeader)` returns `{ jar, issues }`; `jar` is a null-prototype object the presence check inspects via `hasOwnProperty` for the session cookie names. `b.cookies` joins the Worker-side primitive surface via `worker/b.js`. **Fixed:** *Remove `worker-esbuild-parse` smoke gate* — The gate ran `npx esbuild worker/index.js --bundle ...` from `test/smoke.js`. Locally + in CI it worked because `node_modules` was already populated. The container's test stage in the Dockerfile doesn't install dependencies — the smoke test runs against the source tree only — so `npx` had to download esbuild fresh, which the Cloudflare Workers Builds environment refuses (network-restricted build stage). Gate removed to unblock the auto-deploy; replacement gate (parser that doesn't need esbuild) tracked for a follow-up.

- v0.0.99 (2026-05-23) — **`worker-esbuild-parse` smoke gate — catches the node-tolerates / esbuild-refuses syntax mismatch that broke v0.0.95 + v0.0.97 deploys.** v0.0.95 and v0.0.97 shipped `worker/index.js` with a missing closing brace on `_warmingHtml`. Node's parser tolerated the unterminated declaration; `node --check` returned OK, smoke passed, the commits merged. Wrangler's esbuild refused with `Unexpected end of file` and blocked the Cloudflare Workers Builds deploy on both versions. v0.0.98 restored the brace. This patch adds a `worker-esbuild-parse` gate to `test/smoke.js` that runs `npx esbuild worker/index.js --bundle --format=esm --platform=neutral` (with the `@cloudflare/containers` + `node:*` imports externalised so the local run doesn't need the polyfills wrangler ships at deploy time) — parse-success-only check that catches the regression class before the commit lands. **Added:** *`worker-esbuild-parse` static gate in `test/smoke.js`* — Runs `npx esbuild worker/index.js --bundle --format=esm --platform=neutral --external:@cloudflare/containers --external:node:* --log-level=error` against the Worker entrypoint. esbuild's parser is stricter than Node's at EOF — it catches unterminated function declarations, unclosed braces / parens / template literals, and similar shapes that node tolerates. The gate runs alongside the existing release-notes-rollup / changelog-in-sync / vendor-drift checks; verified by injection that an appended `function _badInject() {` trips it. The actual bundle resolution (Cloudflare Workers Builds runs `wrangler deploy` which composes the same esbuild + the nodejs_compat polyfills) stays the source of truth for runtime — this gate is parse-only, fast, and runs on every local + CI smoke.

- v0.0.98 (2026-05-23) — **Restore the missing closing brace on `_warmingHtml` — wrangler bundle was failing at EOF.** An earlier patch trimmed too many trailing lines from `worker/index.js` while removing a detector-injection leftover, accidentally taking the closing `}` of `_warmingHtml` along with it. `node --check` parsed the file fine (Node's parser is more forgiving at EOF) but wrangler's esbuild reported `Unexpected end of file` because the function declaration was unterminated. v0.0.95 / v0.0.97 builds both failed on Cloudflare Workers Builds because of this. The brace is back. The smoke + detector gates passed at every prior ship because Node's parser tolerated the missing brace; adding a brace-balance gate to smoke is the next-shipped follow-up. **Fixed:** *`_warmingHtml`'s closing brace restored* — The function body's string-concat return statement ends on the same line as the closing brace in the source template, and an earlier `head -n -N` trim that was meant to undo a detector-injection took the brace along with the injection. Node's `node --check` accepted the unterminated declaration (its parser doesn't require the brace at EOF in the same way esbuild does) but wrangler / esbuild refused with `Unexpected end of file`. v0.0.95 and v0.0.97 Cloudflare Workers Builds deploys both failed on this — they're now unblocked.

- v0.0.97 (2026-05-23) — **Guest `/cart` on the edge + emergency fix for stray detector-injection leftover that broke wrangler bundling on v0.0.95.** v0.0.95 shipped with a one-line leftover at the bottom of `worker/index.js` — a stray `function _testCatchReturnNull() {` from an earlier detector-verification injection that wasn't fully cleaned up. `node --check` accepted the unterminated declaration but wrangler's esbuild refused with `Unexpected end of file`, blocking the v0.0.95 auto-deploy on Cloudflare Workers Builds. This patch removes the leftover and ships v0.0.96's guest-`/cart` edge route at the same time. The release-notes/v0.0.96.json file lands in this ship too (PR #96 was closed in favour of this consolidated release). **Added:** *Guest `/cart` rendered at the edge (carried from the closed PR #96)* — GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Cookie-bearing visitors fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. Session-cookie detection reuses `_hasSessionCookie` — same predicate that's gated the edge cache since v0.0.78. **Fixed:** *Stray `function _testCatchReturnNull() {` removed from `worker/index.js`* — A detector-verification injection that should have been fully reverted left a single unterminated `function` declaration at EOF. Node's parser tolerates the shape (`node --check` returned OK) but wrangler's esbuild reports `Unexpected end of file` and refuses to build, blocking the v0.0.95 Cloudflare Workers Builds deploy. The leftover is gone.

- v0.0.96 (2026-05-23) — **Guest `/cart` rendered at the edge — no session cookie, no container hop.** GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — the same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Visitors with a session cookie fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. The session-cookie presence check uses the same `_hasSessionCookie` predicate that's gated the edge cache since v0.0.78, so guest detection is consistent across both paths. **Added:** *Guest `/cart` rendered at the edge via the bundled `renderCart`* — When `_hasSessionCookie(request)` returns false (no `shop_sid` or `shop_auth` in the cookie header), the dispatcher routes `/cart` to `_edgeCartEmpty` instead of `_forwardToContainer`. The handler invokes `renderCart` with `lines: []`, zero totals, an empty `productLookup` — the same `CART_EMPTY_PAGE` branch a freshly-arrived visitor would have seen via the container, served in ~90ms instead of ~700ms cold. Authenticated visitors (cookie present) continue through the container path, where the framework's vault decrypts the session and looks up the real cart row. Security headers + HTML minification + the edge-error fallback all apply uniformly via the existing `_html` + `_edgeError` helpers.

- v0.0.95 (2026-05-23) — **Security headers on every edge response — closes the gap with the container's `b.middleware.securityHeaders` pipeline.** Audit found a real regression introduced when storefront read routes moved to the edge in v0.0.76. The container's `b.middleware.securityHeaders` pipeline attaches `Content-Security-Policy`, `Permissions-Policy`, `Referrer-Policy`, `X-Frame-Options`, `Cross-Origin-{Opener,Resource}-Policy`, `Origin-Agent-Cluster`, `X-DNS-Prefetch-Control`, `X-Content-Type-Options`, and `Strict-Transport-Security` to every response. Edge-rendered routes bypassed that pipeline and carried only the Cloudflare-zone defaults (`HSTS` + `X-Content-Type-Options`) — losing CSP, Permissions-Policy, Referrer-Policy, and X-Frame-Options against the storefront's most-touched surface. This patch attaches the canonical set on every Worker response (HTML / RSS / sitemap.xml / 4xx / 5xx / warming page), restoring the security posture the framework's pipeline documents. **Security:** *Edge responses now carry the canonical security-header set — CSP / Permissions-Policy / Referrer-Policy / X-Frame-Options* — `worker/index.js` defines `_SECURITY_HEADERS` mirroring the literal values the framework's `b.middleware.securityHeaders` pipeline emits (`lib/vendor/blamejs/lib/middleware/security-headers.js#DEFAULT_*`). The shared `_withSecurityHeaders(base)` helper merges the canonical set with per-response headers (content-type, cache-control, retry-after, etc.) without callers having to know each one. Applied to every Worker-constructed Response: storefront read HTML (`_html`), static policy pages (`_staticHtml`), edge 500 (`_edgeError`), edge 404 (product + blog), `/sitemap.xml`, `/feed.xml`, `/newsletter` POST results, the container-warming page, and both XML/text 503 fallbacks. Container responses already carried these headers via the framework pipeline; edge responses now match.

- v0.0.94 (2026-05-23) — **Schema.org JSON-LD on product + blog-article pages — rich-result eligibility for Google / Bing.** Every edge-rendered `/products/:slug` now carries a `<script type="application/ld+json">` block with the Schema.org Product graph: `name`, `description`, `image`, `sku`, and an `AggregateOffer` collapsing the per-variant price range into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Every `/blog/:slug` carries an Article graph: `headline`, `image`, `datePublished` / `dateModified` (ISO 8601), `author`, `description`. Embedded JSON survives the inline-in-HTML XSS surface via the `</` → `<\/` rewrite the new `jsonLdScript` helper applies; standard `JSON.stringify` covers the `"` / `\` escapes. Composes through `worker/render/_lib.js#jsonLdScript`. **Added:** *`jsonLdScript(data)` helper in `worker/render/_lib.js`* — Serialises a JS object as a `<script type="application/ld+json">` block. `JSON.stringify` handles `"` and `\` escapes; an additional `</` → `<\/` rewrite neutralises any literal `</script>` substring inside a value (defense-in-depth — Schema.org fields don't ship HTML in the supported vocabulary, but the rewrite is the canonical XSS escape for inline JSON-in-HTML). Throws `TypeError` on null / non-object input. · *Schema.org `Product` JSON-LD on `/products/:slug`* — Surfaces in Google's product-rich-result panel (price + availability), Bing Shopping, etc. The aggregate-offer block collapses every variant's price into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Decimal precision adjusts for zero-decimal currencies (JPY / KRW divide by 1, format as integer; everything else divides by 100, two decimals). `image` array carries the hero media's full URL when present. · *Schema.org `Article` JSON-LD on `/blog/:slug`* — Surfaces in Google's article-rich-result panel. Fields: `headline`, `image` array (falls back to brand logo when no hero), `datePublished` + `dateModified` as ISO 8601 strings (composed from the article row's `published_at` + `updated_at` epoch-ms columns), `author` as a `Person` graph, `description` from `meta_description` or the first 240 chars of the body.

- v0.0.93 (2026-05-23) — **HTML minification at render time — ~10% transfer-byte reduction on every edge-rendered page.** `worker/render/_lib.js` exports `minifyHtml(html)` — a conservative inter-tag whitespace collapser that strips the indentation the source templates carry for readability. Preserve-tag content (`<pre>`, `<script>`, `<style>`, `<textarea>`, `<code>`) stays untouched because whitespace inside those tags is semantically meaningful. The minifier runs on every `_html()` + `_staticHtml()` response from the Worker; cached responses store the minified bytes so the cache-hit path serves smaller payloads too. Empty-home page goes from 17560 → 15840 bytes (-9.8%); populated pages see proportionally larger reductions because per-product card HTML is indentation-heavy. **Added:** *`minifyHtml(html)` in `worker/render/_lib.js` — conservative inter-tag whitespace collapse* — Stashes every `<pre>` / `<script>` / `<style>` / `<textarea>` / `<code>` block behind sentinel tokens (control-character sequences impossible in operator-rendered HTML), runs a `>\s*\n\s*<` → `><` collapse on the remainder, then reinstates the preserved blocks. Text content untouched (text never carries `>...<` boundaries internally). The minifier is composable from any render module that wants minification per-output, and `_html()` + `_staticHtml()` in `worker/index.js` apply it to every edge-rendered response. · *Edge-rendered + cached responses serve the minified bytes* — `_html(body, method)` now wraps the body in `_minify(body)` before constructing the Response. Cached responses in `caches.default` store the minified bytes, so repeat visitors at the same PoP get the already-shrunk payload. Empty-home page: 17560 → 15840 bytes (-9.8%); populated pages compress proportionally more because per-product card HTML in `worker/render/home.js` is multi-line + indented.

- v0.0.92 (2026-05-23) — **POST `/newsletter` on the edge — every page's footer submit no longer hits the container.** The footer newsletter form lives on every storefront page. The POST submit used to hop through the container's storefront mount; this patch wires it directly into the Worker. Email goes through `b.guardEmail.validateAddress` (RFC 5322 single-address validator with header-injection defense). The normalized address hashes via `b.crypto.namespaceHash("newsletter-signup", ...)` so the raw email never lands on disk. Insert is idempotent via D1's `ON CONFLICT(email_hash) DO NOTHING`; the result thank-you renders `new` vs `existing` based on the row-count delta. Sec-Fetch-Site gates cross-site POSTs (CSRF defense); content-type allowlist is `application/x-www-form-urlencoded` only. **Added:** *Edge `POST /newsletter` handler composed off `b.guardEmail` + `b.crypto.namespaceHash` + `b.uuid.v7`* — `worker/index.js` gains a `_edgeNewsletter(request, env)` handler routed when `pathname === "/newsletter" && method === "POST"` and `env.EDGE_RENDER === "on"`. The handler: (1) refuses Sec-Fetch-Site `cross-site` POSTs; (2) refuses non-`application/x-www-form-urlencoded` content; (3) parses the form body via `URLSearchParams`; (4) validates the email through `b.guardEmail.validateAddress` (refused-shape errors render the operator-facing error page without leaking the validator's internal code); (5) lower-cases the address and hashes it via `b.crypto.namespaceHash("newsletter-signup", normalized)`; (6) generates a row id via `b.uuid.v7`; (7) issues an idempotent insert against `newsletter_signups` with `ON CONFLICT(email_hash) DO NOTHING`; (8) renders `renderNewsletterThanks` differentiating `new` from `existing` based on `r.meta.changes > 0`. All `b.*` primitives compose through the validated `worker/b.js` adapter — no inline reinvention. · *`worker/b.js` exposes `b.guardEmail`* — The framework's email-shape gate ships as `lib/vendor/blamejs/lib/guard-email.js`; this patch adds it to the Worker-side adapter's surface so edge POST handlers compose it the same way server-side code does. The leaf module's transitive requires (`codepoint-class`, `lazy-require`, `gate-contract`, `constants`, `numeric-bounds`, `framework-error`) bundle cleanly under wrangler + nodejs_compat. · *`renderNewsletterThanks` + `renderNewsletterError` rendered through the minimal policy layout* — Both compose `renderTemplate` from `worker/render/_lib.js`. The `existing` path differentiates the copy so a returning subscriber sees "You're already on the list" instead of a generic thanks. The error path runs `b.template.escapeHtml` on the operator-facing message via the b.js adapter.

- v0.0.91 (2026-05-23) — **Ten more codebase-patterns ports — detector count 36 → 46.** Bulk-ports every remaining applicable security / correctness detector from blamejs's catalog so the downstream surface inherits the framework's accumulated discipline in one pass. Covers decompression-bomb defense (`inflate-unzip-without-output-size-cap`, `zlib-decompress-not-via-safedecompress`), auth-path coercion (`buffer-from-string-on-auth-path`), JOSE / JWK confused-deputy (`jwk-import-without-alg-kty-check`, `jose-alg-switch-permissive-default`), SSRF metadata bypass (`ssrf-skip-without-textual-metadata-check`), JWT freshness gating (`optional-iat-age-check-no-required-freshness`), state-machine race conditions (`monotonic-terminal-state-overwrite-without-guard`), external-callback DoS (`external-callback-await-without-timeout`), and OpenMetrics counter exposition (`openmetrics-counter-family-name-mismatch`). All ports preserve their upstream `id` / `primitive` / `regex` / `reason` shape so the lineage stays diff-able against `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. **Detectors:** *`inflate-unzip-without-output-size-cap` — RFC 1951 deflate bomb defense* — Completes the gunzip-cap detector. `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` / `createInflateRaw` / `createUnzip` without a `maxOutputLength` opt is the same amplification class as gzip. Uses the `requires` field to exempt files that name `maxOutputLength` elsewhere. · *`zlib-decompress-not-via-safedecompress` — migration target* — Flags every `zlib.*` decompression in lib/ that hasn't migrated to `b.safeDecompress(buf, { algorithm, maxOutputBytes, maxCompressedBytes })`. The framework primitive composes algorithm allowlist + ratio cap + bomb defense + audit emission in one call. · *`buffer-from-string-on-auth-path` — prototype-pollution coercion defense* — `Buffer.from(String(x))` on an auth-relevant input lets a polluted `toString()` redirect the byte sequence the comparison observes. Validate Buffer-or-string explicitly; refuse other shapes with a typed error. · *`external-callback-await-without-timeout` — operator-callback DoS defense* — Awaiting an operator-supplied callback (`_externalStore`, `_externalSink`, etc.) without a `b.safeAsync.withTimeout` bound means a stalled network call stalls the entire emit chain. Uses the `requires` field to exempt files that compose the timeout helper somewhere. · *`monotonic-terminal-state-overwrite-without-guard` — state-machine race defense* — State machines with a `done` (or terminal) flag whose async handler writes `state.done = true; state.X = ...` must check `if (state.done) return` first; a late-arriving handler otherwise clobbers an earlier terminal state. · *`optional-iat-age-check-no-required-freshness` — token-freshness gate* — `if (typeof X.iat === "number" && now - X.iat > maxAge)` short-circuits to no-check on missing iat. The verifier must refuse missing-iat first OR gate on `(iat is number OR server-nonce present)` before the age comparison. · *`ssrf-skip-without-textual-metadata-check` — SSRF metadata-IP defense* — Any SSRF-skip path (proxy short-circuit, operator-pinned IP, custom dnsLookup) must call `b.ssrfGuard.checkUrlTextual(url)` first; metadata IPs (169.254.169.254 / fd00:ec2::254) are never overridable. · *`jwk-import-without-alg-kty-check` — JWK confused-deputy defense* — `crypto.createPublicKey({ key: jwk, format: "jwk" })` without a sibling `_assertAlgKtyMatch(alg, jwk)` lets an attacker pass an RSA-marked key as EC (or vice versa). Validate alg / kty / use match BEFORE the import. · *`jose-alg-switch-permissive-default` — alg-confusion defense* — A `switch (alg)` with a permissive default (`return` / `break`) accepts unknown algorithm strings, opening HS256-as-RS256 and `none` attacks. The default branch must `throw` with a typed error. · *`openmetrics-counter-family-name-mismatch` — exposition correctness* — OpenMetrics counter `# HELP / TYPE / UNIT` metadata and `<name>_total` sample lines must use the same family identifier. Derive the exposed name once at the top of the per-metric loop.

- v0.0.90 (2026-05-23) — **Two more codebase-patterns ports — `raw-sql-identifier-interpolation` + `timing-safe-equal-utf8-without-shape-guard`.** Two more security detectors ported from blamejs's catalog. `raw-sql-identifier-interpolation` catches `FROM " + variable +` shapes (and similar for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN) where the SQL identifier composes via string concat instead of `b.safeSql.quoteIdentifier`. The first scan caught eight call sites in `lib/pwa-manifest.js` where the `table` variable is a primitive-controlled ternary literal (`kind === "manifest" ? "pwa_manifests" : "pwa_sw_configs"`) and never traces back to operator input — added a file-level allow marker citing the bounded two-element domain. `timing-safe-equal-utf8-without-shape-guard` flags `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes that can throw on UTF-8 byte-length divergence — guard with a byte-shape check before encoding, or use `b.crypto.timingSafeEqual` which wraps the validation. **Added:** *`raw-sql-identifier-interpolation` codebase-patterns detector* — Catches SQL identifier interpolation via string concat — `FROM " + table +` and similar shapes for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN. Bound parameters can only carry values, not identifiers; the proper composition is `b.safeSql.quoteIdentifier(name, dialect)` which runs the framework's `validateIdentifier` (length 1–63, charset gate, reserved-word + sqlite_-prefix refusal) before emitting the dialect-correct quoted form. Detector skips variables prefixed with `q` / `Q` / `quoted` (already-validated identifier convention). Ported from blamejs's catalog. · *`timing-safe-equal-utf8-without-shape-guard` codebase-patterns detector* — Catches `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes where the two inputs may diverge in UTF-8 byte length even when their string `.length` matches — the underlying nodeCrypto call throws `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`. Either restrict to ASCII-only domains (token / hex / base64url shapes), or compare byte lengths explicitly before the call. `b.crypto.timingSafeEqual` wraps this with the boundary validation. Ported from blamejs's catalog. **Changed:** *`lib/pwa-manifest.js` carries a file-level `allow:raw-sql-identifier-interpolation` marker* — First scan caught eight `"<KEYWORD> " + table + " "` interpolations in pwa-manifest.js. The `table` variable is set by a primitive-controlled ternary on the `kind` parameter (`"pwa_manifests"` or `"pwa_sw_configs"` — bounded two-element domain at write time, never operator input). File-level allow marker documents the exception in place; the detector still fires on any real operator-controlled identifier interpolation. Detector count: 34 → 36.

- v0.0.89 (2026-05-23) — **`/blog` list + `/blog/:slug` article-detail rendered at the edge from D1.** The sitemap shipped in v0.0.87 references `/blog/:slug` URLs but neither the list nor the detail page existed at the edge. This patch wires both: `/blog` reads the 12 most recent published articles from D1 and renders a card grid; `/blog/:slug` reads the article row and renders the body as paragraph-broken HTML (markdown rendering deliberately not interpreted at the edge — operators who want markdown formatting store pre-rendered HTML in the `body` column). Missing slugs serve the same edge 404 the products route uses. The blog layout adds a `<link rel="alternate" type="application/rss+xml">` so feed-readers auto-discover `/feed.xml` from any blog page. **Added:** *`/blog` list page served from the edge* — `worker/data/catalog.js` exposes `listBlogArticles(DB, { limit, offset })` returning slug + title + author + meta + hero + published_at for published articles, newest first. The list renderer composes a card grid; each card links to `/blog/<slug>`. An empty result set falls back to a clean empty-state with a link to the RSS feed. Empty + populated states both render through `renderTemplate` (the strict templater) so XSS-relevant fields (title, author, lede) compose `b.template.escapeHtml` on substitution. · *`/blog/:slug` article-detail page served from the edge* — `getBlogArticleBySlug(DB, slug)` returns the full row (body + tags + meta) for `status = 'published'` articles. `renderBlogArticle` composes the article's body via `_paragraphsFromPlainText` — splits on blank lines, `b.template.escapeHtml`s each paragraph, wraps in `<p>` — so the body column is plain-text-safe even when operators write raw markup. Missing slugs serve the same edge 404 as `/products/:not-found`. OpenGraph `og:type` is `article`; `og:image` falls back to the brand logo if no `hero_image_url`. · *Blog layout adds RSS auto-discovery + a Blog nav entry* — Every page rendered through `worker/render/blog.js` carries `<link rel="alternate" type="application/rss+xml" href="/feed.xml">` so RSS-aware browsers and feed readers auto-discover `/feed.xml` from `/blog`. The header nav gains a `Blog` entry alongside Shop / Cart.

- v0.0.88 (2026-05-23) — **Four more codebase-patterns ports + scanner `requires` field support + vendor v0.12.9.** The codebase-patterns scanner gains a `requires` field that exempts a primary regex hit when a paired bounding opt appears anywhere in the same file (e.g. `zlib.gunzipSync(...)` paired with `maxOutputLength: <const>`). Four more detector ports follow from blamejs's catalog: `gunzip-without-output-size-cap` (zip-bomb decompression cap), `audit-action-with-hyphen` (validator-refused hyphen segments), `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass the strict validator), `wildcard-suffix-match-without-single-label-check` (DNS wildcard `*.example.com` must refuse single-label matches per RFC 6125 §6.4.3). Vendor refresh to v0.12.9. **Added:** *Scanner `requires` field — secondary whole-file check exempts a primary hit when paired with a documented opt* — `_check` now reads an optional `requires` regex on each detector entry. When set, a primary hit is exempted only if the same file's contents match `requires` somewhere. The companion check covers patterns where the call shape is correct ONLY when paired with a bounding option elsewhere (e.g. `zlib.gunzipSync(buf)` paired with `maxOutputLength: <byte-constant>`). Ported from blamejs's catalog convention. · *Four new ported detectors* — `gunzip-without-output-size-cap` (DoS via decompression-bomb amplification; require `maxOutputLength` on every `zlib.gunzipSync` / `createGunzip` / brotli-decompress); `audit-action-with-hyphen` (action segments with `-` fail the `[a-z][a-z0-9_]*` validator — use snake_case); `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass `audit.record()`; `ok` / `fail` / `error` / etc. are silently normalized by `safeEmit` but the canonical strings belong at the call site); `wildcard-suffix-match-without-single-label-check` (RFC 6125 §6.4.3 — a `*.example.com` wildcard covers exactly one label, never zero / single-label). Detector count goes 30 → 34. **Changed:** *Vendored blamejs refreshed from v0.12.8 to v0.12.9* — `bash scripts/vendor-update.sh blamejs v0.12.9` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.8 and v0.12.9. · *`lib/customer-import.js` per-row error outcome string carries an inline allow marker* — The detector flagged `outcome: "error"` on a per-row import-result object in `lib/customer-import.js:271`. Inspection confirmed it's a per-row operator-facing result, NOT an `audit.emit` payload — different vocabulary. Added an inline `// allow:non-canonical-audit-outcome — per-row operator-facing import result; not an audit.emit outcome` marker documenting the exception in place. The non-canonical-audit-outcome detector still fires on any real `audit.emit({outcome: "error"})` call.

- v0.0.87 (2026-05-23) — **`/sitemap.xml` rendered at the edge from D1 — fourth and final broken footer link fixed.** `/sitemap.xml` was the last footer link still returning 403 via the container's bot-guard. The Worker now generates the urlset directly from D1 — every active product slug and every published blog article slug joins the home + `/privacy` + `/terms` static-page roster. Output matches sitemaps.org/schemas/sitemap/0.9; XML escape composes `b.template.escapeHtml` through the strict `renderTemplate`, and slugs URL-encode at the path segment so a hypothetical slug with spaces or ampersands produces a crawler-valid URL. Cached `public, max-age=600, s-maxage=3600` (matches `/feed.xml`). **Added:** *`/sitemap.xml` served from the edge — generated from D1 in two queries* — `worker/data/catalog.js` exposes `listActiveProductSlugs(DB)` and `listPublishedBlogSlugs(DB)` — each pulls slug + updated_at with a 50,000-row cap (the sitemap protocol's per-file URL limit). The two queries fan out in parallel via `Promise.all`. `worker/render/sitemap.js` carries the urlset template + per-entry composition; `renderTemplate` from `worker/render/_lib.js` provides the per-value HTML / XML escape (same primitive everything else in the Worker uses). Slugs URL-encode via `encodeURIComponent` before insertion so a hypothetical `mug & co` slug renders as `mug%20%26%20co` in the `<loc>`. Each entry carries `<lastmod>` (ISO date from the row's updated_at), `<changefreq>` (daily for products, weekly for blog, monthly for legal), and `<priority>` (1.0 home, 0.8 products, 0.6 blog, 0.3 legal). Cached `public, max-age=600, s-maxage=3600`. The handler falls back to a `text/plain; 503` with `retry-after: 60` on D1 failure — never escalates to the container.

- v0.0.86 (2026-05-23) — **Edge 404 / 500 / `/feed.xml` rendered through the worker — exceptions no longer escalate to the container, plus two new codebase-patterns detectors.** Edge handlers now own their own failure modes. The previous `_edgeHome` / `_edgeSearch` / `_edgeProduct` shape caught render exceptions and returned `null`, which routed back through the dispatcher to `_forwardToContainer` — the container then either bot-guard-403'd the visitor or ran the same primitive surface that just threw on the edge. Each handler's `catch` now serves a 5xx via the new `renderInternalError` helper (designed page, no container hop) and the failure logs to the Worker's observability sink. `/products/:slug` for a missing slug renders an edge 404 instead of escalating. `/feed.xml` generates an RSS 2.0 channel from `blog_articles` via D1 + the same templater the rest of the worker uses. Two new codebase-patterns detectors catch the regressions: `edge-handler-catch-returns-null` flags any worker catch that returns `null` (routing-dispatch returns are still legitimate; only catches are flagged); `unvalidated-env-url-as-origin` flags `fetch(env.X_URL)` / `origin: env.X_URL` / `origin: "https://..." + env.X` shapes that bypass `b.safeUrl.parse`. Both verified via injection. **Added:** *Edge 404 page for missing product slugs — no container hop* — `worker/render/policy.js` gains a `renderNotFound({ what, shopName, version })` helper composed off the same minimal layout as `/privacy` and `/terms`. `_edgeProduct` now returns the rendered 404 with HTTP 404 + `cache-control: public, max-age=60, s-maxage=300, must-revalidate` when `getProductBySlug` resolves to null — previously the handler returned null and the request fell through to the container's bot-guard 403 (which crawlers and visitors landed on alike when a slug had been archived or never existed). · *`/feed.xml` RSS 2.0 feed served from the edge* — `worker/render/feed.js` renders a complete `<rss version="2.0">` channel from a `blog_articles` row set; `worker/data/catalog.js` exposes `recentBlogArticles(DB, { limit })` (default 20). Each article surfaces as an `<item>` with `<title>`, `<link>`, `<guid isPermaLink="true">`, `<pubDate>` (RFC 822-formatted), `<description>`, and `<author>`. XML escape composes `b.template.escapeHtml` — XML 1.0 §4.6 accepts the same `&#x27;` numeric reference the HTML primitive emits, so the framework primitive covers both surfaces. The handler returns `cache-control: public, max-age=600, s-maxage=3600` so readers re-fetch every 10 minutes while the edge holds for an hour. The footer's RSS link is no longer dead. **Changed:** *Edge handlers no longer escalate render exceptions to the container* — `_edgeHome`, `_edgeSearch`, and `_edgeProduct` previously returned `null` on catch — which the dispatcher then routed to `_forwardToContainer`. The container couldn't fix a render-side bug (it runs the same primitive surface that just threw), the visitor experience of fallback-to-different-backend is worse than a clean error page, and the escalation hid the bug from observability. Each handler now serves a 5xx via the new `renderInternalError(opts)` helper (designed page, composed off the same minimal layout as `/privacy` and `/terms`) with `cache-control: no-store` so the failure isn't cached. `console.error` to the Worker's observability sink stays explicit; only the silent-escalation path is removed. `/feed.xml`'s 503 path stays plain-text (RSS readers expect that on failure) but no longer falls through to the container. · *Cache-control on static pages now splits `max-age` from `s-maxage`* — `/privacy` and `/terms` previously carried `cache-control: public, max-age=86400, must-revalidate` — browsers held the page for 24h, which delayed any operator-shipped policy update. The new shape is `public, max-age=3600, s-maxage=86400, must-revalidate`: browsers revalidate every hour, Cloudflare's zone cache amortizes the render across the whole zone for 24h. Visitors get the same TTFB; operator-shipped updates propagate in under an hour to every browser. · *Origin URLs in `/feed.xml` + the cache-warmer cron now pass through `b.safeUrl.parse`* — Both call sites previously built the origin via string concat from `env.D1_BRIDGE_URL`. `b.safeUrl.parse` validates the value against the framework's HTTPS-only allowlist (`ALLOW_HTTP_TLS` — refuses `javascript:`, `file:`, `data:` schemes) and returns the normalized URL. The trailing-slash strip leaves a clean origin the per-route path concatenates against. An attacker who somehow set `D1_BRIDGE_URL` to a non-HTTPS scheme would now fail at validation instead of letting the cron warm a hostile origin. · *`/feed.xml` XML rendering now composes through `renderTemplate` (the strict templater)* — Feed XML was built via string concat with inline `b.template.escapeHtml` calls. The refactor routes both the channel envelope and each `<item>` block through the same `renderTemplate` from `worker/render/_lib.js` that the rest of the Worker uses — same `{{name}}` substitution shape, same unknown / unused placeholder refusal at composition time, same per-value escape. XML 1.0 §4.6 accepts the numeric `&#x27;` reference `b.template.escapeHtml` emits, so the HTML primitive covers the XML output too. · *`lib/admin.js` switches from `try { audit.emit(...) } catch {}` to `audit.safeEmit(...)`* — The framework ships `safeEmit` as the drop-silent variant of `audit.emit` — handles sink failure, invalid namespace, and shape errors internally without throwing. The previous local `try/catch` wrapper duplicated that contract; composing the framework primitive removes the duplication and lets the new `raw-audit-emit-without-drop-silent-wrap` detector enforce the discipline globally. **Detectors:** *Eight new `codebase-patterns` detectors — two project-specific + six ported from blamejs's catalog* — `edge-handler-catch-returns-null` and `unvalidated-env-url-as-origin` are project-specific; both verified via injection test. The remaining six are direct ports from the vendored framework's own catalog (`lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`), with `id` / `primitive` / `regex` / `reason` shapes preserved so the lineage stays diff-able against upstream: `number-coerce-or-zero-on-json-source` (validate finite non-negative integer instead of `Number(x) || 0`), `slice1-optional-parseint-silent-default` (refuse empty-digit segment with an explicit throw before parseInt), `utf16-length-as-byte-cap` (`Buffer.byteLength(s, "utf8")` instead of `s.length` for byte caps), `raw-audit-emit-without-drop-silent-wrap` (`b.audit.safeEmit` instead of bare `audit.emit`), `non-ct-iss-compare` (`b.crypto.timingSafeEqual` for issuer compare), `fs-path-from-operator-identifier-without-traversal-refusal` (validate name + explicit `..` refusal before `path.join`). All eight scan the `shop` scope (lib/ + worker/). Detector count goes from 22 → 30.

- v0.0.85 (2026-05-23) — **FSM-name shape detector + email-campaigns rename + /SECURITY.md redirect (auto-deploy fix) + vendor v0.12.8 (audit drop-silent contract restored).** The `email-campaigns` primitive named its FSM `emailCampaign` — camelCase. The framework's audit-action validator at `lib/vendor/blamejs/lib/audit.js:401` enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment, so every transition fired `flush dropped event: audit action must be 'namespace.verb[.qualifier...]' — got: fsm.emailCampaign.transition` and the event was dropped silently. Renamed to `email_campaign`. A new `codebase-patterns` detector `fsm-name-not-audit-action-safe` catches this class of bug going forward — it scans `fsm.define({ ... name: "..." ... })` calls (using a new multi-line scan mode) and refuses any name that doesn't match the per-segment shape. Auto-deploy is also unblocked: the `/SECURITY.md` route now redirects to the GitHub mirror (same shape as `/CHANGELOG.md`) — bundling the file via wrangler's text rule was failing under `wrangler versions upload`. Bundled vendor refresh to blamejs v0.12.8 — upstream restored the `safeEmit` drop-silent contract in the flush handler, eliminating the `flush dropped event: db.init() must be awaited` log spam that layer-1 tests were producing whenever a primitive composed `b.fsm.transition` without the framework's storage layer being initialized. The npm `homepage` field also moves from the GitHub mirror to `https://blamejs.shop` so the registry page links at the live deployment rather than the source repo. **Added:** *`fsm-name-not-audit-action-safe` codebase-patterns detector* — Scans `fsm.define({ ... name: "..." ... })` calls in `lib/` and flags any name that doesn't match the per-segment audit-action shape (`[a-z][a-z0-9_]*`). The pattern spans lines so multi-line `fsm.define({ \n name: "..." \n })` shapes are caught; a new `multiline: true` flag on the detector entry routes the scan through a whole-file regex pass instead of the per-line one. Description points at the canonical audit-action validator + the framework's PQC-first naming convention. · *Multi-line scan mode in `codebase-patterns.test.js`* — The scanner now accepts `{ multiline: true }` per detector. When set, the whole-file content is scanned with the global regex via `matchAll`; line numbers are derived from the byte offset by counting preceding newlines. Per-line scans remain the default — only detectors that need cross-line context (FSM `name:` field validation, future shape checks) opt in. **Changed:** *Vendored blamejs refreshed from v0.12.6 to v0.12.8* — Upstream restored the `b.audit.safeEmit` drop-silent contract in the flush handler at `lib/vendor/blamejs/lib/audit.js:1129` — events queued before `b.db.init()` is awaited (or where storage rejected the write) now drop without the `log.error("flush dropped event: ...")` spam that prior versions emitted. The framework's chain integrity guarantee is unchanged; only the flush-side log behaviour. Layer-1 tests in this codebase wire `query` directly via `node:sqlite` (the framework's storage layer is intentionally not initialized in unit tests), so every primitive that composes `b.fsm.transition` previously caused dozens of dropped-event logs per smoke run. Local smoke goes from ~100 dropped-event logs to zero. · *`package.json` homepage now points at the live deployment* — The `homepage` field was `https://github.com/blamejs/blamejs.shop` so the npm registry page linked at the source repo. Operators landing on `npmjs.com/package/@blamejs/blamejs-shop` were one click away from the reference deployment but the link sent them to the README. Field now reads `https://blamejs.shop` so the registry entry points at the live storefront; the `repository` field still references the GitHub mirror for source. **Fixed:** *`email-campaigns` FSM renamed from `emailCampaign` to `email_campaign`* — The FSM name composes into audit actions as `fsm.<name>.transition`. The framework's audit-action validator (`lib/vendor/blamejs/lib/audit.js:401`) enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment — `emailCampaign` failed validation because of the uppercase `C`, and the audit module dropped every campaign-state transition silently with an error log. Snake_case rename closes the gap. No data shape change — the FSM `name` is an identifier in the framework, not a column value. · *`/SECURITY.md` redirects to the GitHub mirror — unblocks Cloudflare auto-deploy* — 0.0.84's `wrangler [[rules]] type = "Text"` bundling of `SECURITY.md` worked under local `wrangler deploy` but failed under `wrangler versions upload` (the command Cloudflare Workers Builds runs): `✘ [ERROR] No loader is configured for ".md" files`. The route now serves a 302 to `https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` — same pattern as `/CHANGELOG.md`. Both files stay readable to operators; neither bloats the Worker bundle.

- v0.0.84 (2026-05-23) — **`/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md` served from the edge — footer links no longer 404.** The storefront footer has always linked to `/privacy`, `/terms`, `/SECURITY.md`, and `/CHANGELOG.md`, but the container never owned those routes — every footer click landed on a 403 / 404. This patch wires each route into the Worker: `/privacy` and `/terms` render minimal policy pages composed off a stripped-down layout; `/SECURITY.md` serves the in-tree file as `text/markdown` (bundled via a new wrangler `[[rules]]` Text rule); `/CHANGELOG.md` redirects (302) to the GitHub mirror since the file at 303 KB would inflate the gzipped Worker by ~60 KB. All four pages cache 24h `must-revalidate` so the browser + Cloudflare's zone cache hold them across visits. **Added:** *Edge handlers for `/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md`* — `worker/render/policy.js` carries `renderPrivacy(opts)` and `renderTerms(opts)` — minimal layout (no marquee / hero / catalog chrome) with the policy text inlined. Both compose `renderTemplate` from `worker/render/_lib.js` so the substitution + HTML escape semantics match the rest of the Worker. `worker/index.js` routes `/privacy` and `/terms` to those renderers, `/SECURITY.md` to the wrangler-text-bundled in-tree SECURITY.md, and `/CHANGELOG.md` to a 302 redirect at `https://github.com/blamejs/blamejs.shop/blob/main/CHANGELOG.md`. All four respond with `cache-control: public, max-age=86400, must-revalidate` so the browser and Cloudflare's zone cache hold them between visits. · *`wrangler.toml` text-rule for `SECURITY.md` bundling* — `[[rules]] type = "Text" globs = ["SECURITY.md"] fallthrough = true` — wrangler's esbuild loader now treats `SECURITY.md` as a text-imported string. `import SECURITY_MD from "../SECURITY.md"` in `worker/index.js` resolves to the file's content. `CHANGELOG.md` is excluded by glob (303 KB → 60+ KB gzipped Worker bloat); that route uses an HTTP redirect to the GitHub mirror instead.

- v0.0.83 (2026-05-23) — **Cache-warmer cron + cache-key normalization for referral traffic.** A once-a-minute scheduled handler now warms the edge cache for `/` and `/search` so the 60-second cache TTL never lapses at PoPs that have seen at least one real visitor — repeat visitors get the cached response even after a brief idle gap. Cache-key normalization strips common tracking parameters (`utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, `yclid`) before computing the `caches.default` key, so two visitors arriving with different referral params share the same cache entry. The original request URL is preserved end-to-end — the strip only affects which cache bucket the response lives in. **Added:** *Once-a-minute cache-warmer cron — keeps every active PoP's `/` and `/search` entries fresh* — `wrangler.toml` gains `[triggers] crons = ["* * * * *"]` and the Worker now exports a `scheduled(event, env, ctx)` handler that GETs each storefront read route with a browser-shaped header set. The cache TTL is 60s — the once-a-minute warm keeps the cache populated across the gap so a visitor arriving 30 seconds after the previous hit still sees a cache hit instead of a renderer miss. Gated by `env.EDGE_RENDER === "on"`. Auth-cookie-bearing visitors continue to bypass the cache (per-session content stays correct); only the unauthenticated default fetches get warmed. · *Cache-key normalization — referral traffic shares cache entries with direct traffic* — The cache lookup now uses a derived key that strips `utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, and `yclid` query parameters before computing the `caches.default` lookup. The original request URL stays intact end-to-end so analytics and the rendered output still see the unmodified params; the strip only affects which cache bucket the response is stored / retrieved from. A visitor arriving at `/?utm_source=newsletter` now hits the same cache entry as one arriving at `/` directly.

- v0.0.82 (2026-05-23) — **`vendor-update.sh --check` skips gracefully when upstream is unreachable (Cloudflare Workers Builds fix).** The vendor-drift gate inside the container smoke test was failing in build environments that can't reach `api.github.com` — `_latest_tag()` returned empty and the script reported a phantom drift against an empty version string. The committed `lib/vendor/blamejs/` tree is already the source of truth at build time; freshness can only meaningfully be checked when the upstream tag is reachable. The gate now skips with a warning to stderr when the upstream lookup returns empty, instead of failing the build. **Fixed:** *`scripts/vendor-update.sh --check` no longer fails the build when upstream is unreachable* — When `_latest_tag()` returns an empty string (sandboxed CI runner, rate-limited anonymous GitHub API request, air-gapped image), the gate emits `[vendor-check] SKIPPED — could not resolve upstream tag (offline / rate-limited); committed v<X> is the source of truth` to stderr and exits 0. The next operator-run smoke against a network-reachable environment re-verifies freshness. Online behavior is unchanged — when the upstream tag resolves, the gate compares as before and fails on actual drift.

- v0.0.81 (2026-05-23) — **Comprehensive `codebase-patterns` detector catalog for primitive composition + shape alignment with the vendored framework's catalog.** Extends the `codebase-patterns` detector with five additional reinvention catchers (`manual-random-uuid`, `manual-random-bytes`, `weak-hash-sha2`, `manual-createhmac`, `worker-direct-vendor-import`) and aligns every entry's data shape with the vendored framework's canonical catalog at `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js` — `id` / `primitive` (one-line replacement) / `regex` / `allowlist` / `reason`. Existing `console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow` detectors expand from the `lib`-only scope to a new `shop` scope (lib/ + worker/) so the Worker substrate gets the same hygiene gates. The runner prints both the canonical primitive and the deeper reason on failure so the operator-facing fail message points directly at the b.* call that should have been composed. **Added:** *Five additional `codebase-patterns` detectors for blamejs primitive composition* — `manual-random-uuid` (`crypto.randomUUID()` → `b.uuid.v7()` or `b.uuid.v4()`), `manual-random-bytes` (`crypto.randomBytes(n)` → `b.crypto.generateBytes(n)`), `weak-hash-sha2` (`createHash("sha256"|"sha384"|"sha512")` → `b.crypto.sha3Hash(data)` outside explicit protocol exceptions), `manual-createhmac` (`createHmac(...)` → `b.crypto.hmacSha3` / `b.crypto.hmacSha256`), `worker-direct-vendor-import` (Worker code reaching for `lib/vendor/blamejs/lib/*.js` leaf modules outside `worker/b.js` → use the adapter). The four detectors that already existed for `lib`-only enforcement (`console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow`) expand to the new `shop` scope covering both `lib/` and `worker/`. **Changed:** *Detector entry shape aligned with the vendored framework's catalog* — Every entry now carries `id`, `primitive` (the canonical one-line replacement), `regex`, `allowlist`, and `reason` — matching the shape blamejs's own `codebase-patterns.test.js` uses for its 95 internal detectors. The runner prints both the primitive line and the deeper reason on failure so operators see what to compose AND why, not just the regex match. · *Allow markers on the documented exceptions (`worker/index.js` console.*, `lib/pixel-events.js` SHA-256)* — `worker/index.js` carries per-line `allow:console-direct` markers on every `console.log/error` call — Workers have no framework observability sink; `console.*` IS the structured log emission point auto-routed to wrangler tail / Logpush. `lib/pixel-events.js#_sha256Hex` carries inline `allow:weak-hash-sha2` (and the existing `allow:non-shop-require`) markers — Meta CAPI / Google EC / TikTok / Pinterest / Snap CAPI mandate SHA-256 of the normalised identifier on the wire and `b.crypto.sha3Hash` is not a valid substitute.

- v0.0.80 (2026-05-23) — **Worker code composes blamejs primitives through a single `worker/b.js` adapter + four new codebase-patterns detectors catch reinvention.** The Worker now imports the framework primitives it needs through a single validated adapter at `worker/b.js`. The adapter pulls leaf modules from `lib/vendor/blamejs/lib/` directly (the framework entry's `node:tls.DEFAULT_MIN_VERSION` write has no Worker analogue) and re-exports them under the canonical `b.<namespace>` shape. Worker code now reads `b.template.escapeHtml(s)`, `b.money.of(n, c).format("en-US")`, `b.crypto.timingSafeEqual(a, b)`, and `b.crypto.hmacSha256(secret, message)` — same call shape as server-side code. Four new `codebase-patterns` detectors catch the reinvention shapes (per-character HTML-escape regex, `Intl.NumberFormat({style:"currency"})`, hand-rolled timing-safe comparison loop, inline `crypto.subtle.sign("HMAC")`) so future Worker / lib code can't reach for these without being flagged at smoke time. Eight pre-existing reinvention sites in `lib/storefront.js` (the 5-char `_esc`, `_escAttr`, `_orderEsc`, `_xmlEsc` helpers), `lib/wishlist-digest.js`, and `lib/barcodes.js` were refactored to compose `b.template.escapeHtml` — bringing the apostrophe escape (`&#x27;`) defense the previous 4-char shape was missing. **Added:** *`worker/b.js` — single validated blamejs adapter for the Worker substrate* — Imports `crypto`, `template`, `money`, `uuid`, `safe-url`, `safe-sql`, `fsm` leaf modules from `lib/vendor/blamejs/lib/` and re-exports them under the canonical `b.<namespace>` shape. Adds one Worker-side `b.crypto.hmacSha256` extension (the framework's PQC-first policy ships only `b.crypto.hmacSha3` publicly; Stripe webhook verify mandates SHA-256 per their published protocol). The extension composes `node:crypto.createHmac` — the same primitive the framework's internal `hmac()` helper uses. · *Four new `codebase-patterns` detectors — flags blamejs-primitive reinvention* — Adds `worker-render-reinvented-primitive` (per-char HTML escape regex → `b.template.escapeHtml`), `intl-numberformat-currency-reinvented` (`new Intl.NumberFormat({style:"currency"})` → `b.money.of(amount, currency).format(locale)`), `manual-timing-safe-equal` (hand-rolled `diff |= a.charCodeAt(i) ^ b.charCodeAt(i)` loop → `b.crypto.timingSafeEqual`), and `inline-hmac-subtle-crypto` (`crypto.subtle.sign("HMAC", ...)` → `b.crypto.hmacSha256` or the framework's webhook-verify primitive). The detectors scan a new `shop` scope (lib/ + worker/) so they catch reinvention in both surfaces. **Changed:** *Worker render + Stripe verify compose blamejs primitives through `worker/b.js`* — `worker/render/_lib.js` now delegates `escapeHtml`, `escapeAttr`, and the per-value substitution in `renderTemplate` to `b.template.escapeHtml`, and routes `formatPrice` through `b.money.of(BigInt(minor), currency).format("en-US")`. `worker/index.js`'s `_timingSafeEqual` delegates to `b.crypto.timingSafeEqual`. `_verifyStripeSignature` swaps its inline `crypto.subtle.sign("HMAC", ...)` block for `b.crypto.hmacSha256(secret, message)`. The warming-page canonical-URL escape now uses `b.template.escapeHtml`. · *Eight pre-existing inline HTML-escape helpers refactored to `b.template.escapeHtml`* — `lib/storefront.js` had six per-function helpers (`_esc`, `_escAttr` × 3, `_orderEsc`, `_xmlEsc`) that hand-rolled the 4- or 5-char escape. `lib/wishlist-digest.js#_htmlEscape` and `lib/barcodes.js`'s inline barcode-label escape used the same shape. All eight now compose `b.template.escapeHtml` — the four-character helpers gain the apostrophe (`&#x27;`) escape they were missing, closing a small defense-in-depth gap on attribute interpolation against single-quoted attributes.

- v0.0.79 (2026-05-23) — **Container Dockerfile uses the committed vendored tree instead of re-fetching from GitHub at build time.** The container image's `vendor` stage previously ran `bash scripts/vendor-update.sh blamejs latest` to re-fetch the vendored blamejs at image-build time. That stage fails in build environments without outbound `api.github.com` access (rate-limited anonymous requests, sandboxed CI runners, etc.) — most visibly on Cloudflare Workers Builds. The committed `lib/vendor/blamejs/` tree is already the source of truth, and the smoke gate's `vendor-update.sh --check` already enforces it matches the latest upstream release tag, so the in-build re-fetch is redundant. The vendor stage now just `COPY`s the committed tree. **Fixed:** *`Dockerfile` vendor stage no longer requires `api.github.com` access at image-build time* — Removed the `RUN bash scripts/vendor-update.sh blamejs "${BLAMEJS_TAG}"` step and the `BLAMEJS_TAG` build arg. The vendor stage now copies `lib/vendor/` from the build context directly. Image builds in network-restricted environments (Cloudflare Workers Builds, air-gapped CI runners) now succeed where they previously errored on `could not resolve latest blamejs release tag`. The committed vendor stays the source of truth; freshness is enforced by the smoke gate's `vendor-update.sh --check`, not the image build.

- v0.0.78 (2026-05-23) — **Edge HTML cache + SmartPlacement — unauthenticated storefront reads drop to ~10-30ms TTFB.** Two complementary perf wins layer on top of the v0.0.77 edge render. The Worker now wraps `/`, `/search`, and `/products/:slug` with a `caches.default` lookup keyed on the full request URL; only unauthenticated requests (no `shop_sid` / `shop_auth` cookie) are eligible. Cached responses carry `cache-control: public, max-age=60, stale-while-revalidate=300` so the edge keeps serving HTML while a background revalidate refreshes the data. Authenticated requests skip the cache entirely so per-session content stays correct. Separately, `wrangler.toml` gains `[placement] mode = "smart"` so the Worker compute runs close to D1 rather than close to the user — eliminates the cross-region D1 round-trip on cache miss. **Added:** *Edge HTML cache for unauthenticated storefront reads — 60s TTL + 300s stale-while-revalidate* — `worker/index.js` wraps `_edgeRender` with a `caches.default` lookup. Requests carrying any `shop_sid` / `shop_auth` cookie bypass the cache and hit the renderer directly. Cache misses store the rendered HTML via `ctx.waitUntil(cache.put(...))` so subsequent visitors get an edge-cache hit at the same PoP. Stale-while-revalidate lets the edge serve last-known-good HTML during the 5-minute background-revalidate window. Cached responses carry an `x-edge-cache: hit` header for observability; misses carry `x-edge-cache: miss`. · *SmartPlacement enabled — Worker runs close to D1, not close to the user* — `[placement] mode = "smart"` in `wrangler.toml`. Cloudflare's placement engine routes the Worker compute to a region close to the bound D1 database instead of the user-nearest PoP. The edge still serves the eventual response to the user via standard routing — only Worker execution moves. Cuts the D1 round-trip on cache miss from ~30-50ms (cross-region) to sub-ms (intra-region).

- v0.0.77 (2026-05-23) — **Edge-render catalog queries collapsed to JOINs — home / search / PDP render in ~160ms TTFB.** Home + search now serve from a single window-functioned JOIN that returns every active product with its first variant's price and first media row pre-joined. PDP serves from two parallel queries — one variants × prices LEFT JOIN, one media list. Worker compute time dropped from 2.8s (serial N+1) to 81ms (single JOIN). End-to-end TTFB on the live storefront is now ~160ms, down from the original ~3.4s container cold-start. EDGE_RENDER default flips to "on" so the perf win is the new baseline. Also fixes split-shipments ORDER BY id (UUIDv7 within-ms collision) and refreshes vendored blamejs to v0.12.6. **Changed:** *Edge catalog reads collapsed to JOINs — single round trip for home / search* — `worker/data/catalog.js` now exposes `listActiveProducts(DB, {limit, offset, currency})` and `searchProducts(DB, {q, limit, currency})` returning pre-decorated rows: each product carries `starting_price_minor` + `starting_price_currency` + `hero_media` joined inline. Two `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY position ASC, created_at ASC)` window subqueries pick the first variant + first media per product; `LEFT JOIN prices` off the hero variant carries the active price for the requested currency. PDP now uses `listVariantsWithPrices(DB, productId, currency)` — one `variants LEFT JOIN prices` query returns every variant with its current price column. The previous N+1 helpers (`listVariantsForProduct`, `currentPrice`) are removed from the catalog module. · *`EDGE_RENDER` default flipped from `"off"` to `"on"` in `wrangler.toml`* — After the JOIN refactor put live-storefront TTFB at ~160ms, the edge-render path is the new baseline. Operators who want the legacy container-render path for the storefront read routes can set `EDGE_RENDER = "off"` in their `wrangler.toml` override. · *Vendored blamejs refreshed from v0.12.5 to v0.12.6* — `bash scripts/vendor-update.sh blamejs v0.12.6` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.5 and v0.12.6. **Fixed:** *`split-shipments` — `splitsForOrder` was ordering by `id DESC` (UUIDv7) instead of `proposed_at DESC`* — Two plans created in the same wall-clock millisecond shared a UUIDv7 timestamp prefix; the suffix is random, so `ORDER BY id DESC` returned them in undefined order. The v0.0.71 ship added a monotonic `_now()` to the `proposed_at` column, but the query still sorted on `id`. Changed to `ORDER BY proposed_at DESC, id DESC` so the monotonic timestamp is the primary sort key and the random ID suffix only breaks ties when timestamps truly match.

- v0.0.76 (2026-05-23) — **Edge-render path for storefront read routes — `/`, `/search`, `/products/:slug` served from the Worker without a container hop + vendor refresh to blamejs v0.12.5.** The storefront read-side render now runs at the edge. `/`, `/search`, and `/products/:slug` are rendered by the Cloudflare Worker reading D1 directly through the bound binding, then returning HTML — no container hop, no Durable Object handoff. Public visitors see TTFB drop from ~3.4s (container wake-up) to ~50ms. Gated by the `EDGE_RENDER` env var so operators can roll back per-deploy without a re-push. Write-side routes (POST /cart/lines, /checkout, /admin, webhooks) continue to land on the container. Bundles the vendored blamejs refresh from v0.12.4 to v0.12.5 and three CI-tight 50ms `waitUntil` timeouts bumped to 5s for runner-contention resilience. **Added:** *Worker edge-render — `/` home, `/search`, and `/products/:slug` rendered without a container hop* — New Worker-side modules under `worker/render/` (`_lib.js`, `home.js`, `product.js`, `search.js`, `cart.js`) and `worker/data/catalog.js` carry the storefront templates + D1 read-side queries. When `env.EDGE_RENDER` is `"on"`, the Worker queries D1 directly via the bound `env.DB` binding and returns HTML inline. The container's storefront mount continues to own the same routes when the flag is off, so the toggle is operator-controlled and reversible. Cart-count display in the header is fixed at zero on edge-rendered pages until the sealed-session decrypt path lands in a follow-up — visitors landing on `/cart` still see the live count from the container path. · *`EDGE_RENDER` and `SHOP_NAME` wrangler vars* — `wrangler.toml` gains two new `[vars]` entries: `EDGE_RENDER` (default `"off"` — flip to `"on"` to enable the edge-render path) and `SHOP_NAME` (default `"blamejs.shop"` — surfaces in the rendered `<title>` and OpenGraph metadata). **Changed:** *Vendored blamejs refreshed from v0.12.4 to v0.12.5* — `bash scripts/vendor-update.sh blamejs v0.12.5` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.4 and v0.12.5. **Fixed:** *Three 50ms `waitUntil` timeouts bumped to 5s — `live-chat`, `fraud-screen`, `support-tickets` tests* — Nine call sites across `test/layer-1-state/live-chat.test.js`, `test/layer-1-state/fraud-screen.test.js`, and `test/layer-1-state/support-tickets.test.js` were polling for `Date.now()` to advance past a captured timestamp on a 50ms budget. Under CI runner contention that budget sometimes exhausts before the monotonic-clock tick lands. Same fix the v0.0.69 ship applied to `return-labels.test.js`; same rule §11 root cause.

- v0.0.75 (2026-05-22) — **Sixteen new primitives across operator tooling, vendor / inventory ops, marketing automation, and storefront content.** Sixteen primitives ship together. Operator + admin: customerImpersonation, carrierAccounts, operatorApprovals, customerMerge, operatorHelpCenter, eventLog. Vendor + inventory: vendorInvoices, inventoryAudits, smartRestocking, taxRemittance. Marketing + retention: winbackCampaigns, wishlistDigest. Storefront: blogArticles, categoryNavigation, productCompare, lineGiftWrap. **Added:** *`customerImpersonation` primitive — operator login-as-customer with audit* — 32-byte plaintext token returned once; namespaceHash at rest. 60-min default TTL. Capability gate via operatorRoles.hasPermission. notifyCustomer fan-out via injected notifications. actionsRecord builds the audit trail. Migration `0190`. · *`carrierAccounts` primitive — per-operator carrier API account management* — Carrier enum: ups / fedex / usps / dhl / canada_post / royal_mail / australia_post. All secrets hashed via namespaceHash under per-field namespaces. rotateCredentials with 24h grace. verifyCredentials timing-safe. Migration `0191`. · *`operatorApprovals` primitive — multi-step approval workflows* — Defines workflows with required_approvers + required_capability + escalation_after_hours. castVote dedup via UNIQUE(request_id, approver_id). Status FSM pending / approved / rejected / executed / cancelled / escalated. Migration `0192`. · *`vendorInvoices` primitive — vendor-issued invoices with PO reconciliation* — FSM received → approved → paid with disputed + voided side branches. Partial-pay first-class. UNIQUE(vendor_slug, invoice_number) for dedup. reconcileAgainstPOs returns variance summary. agingReport bucketing. Migration `0193`. · *`customerMerge` primitive — operator merge of duplicate customer accounts* — Reparents orders + subscriptions + loyalty + reviews + addresses + paymentMethods atomically (pre-flight all, then write all). 7-day rollback window. Jaro-Winkler-scored findDuplicateCandidates. customer_merge_redirects table for forwarding. Migration `0194`. · *`productCompare` primitive — storefront 2-4 product comparison* — Cap of 4 per session. compareTable returns per-attribute rows for the table render. Default attributes: price / sku / brand / vendor / weight / dimensions / inventory_status. defineCompareAttribute adds custom rows. popularCompares for analytics. Migration `0195`. · *`winbackCampaigns` primitive — multi-step re-engagement for lapsed customers* — Operator defines lapse_days_min + lapse_days_max + steps (delay + template + optional coupon). scanForLapsedCustomers finds candidates; enrollCustomer schedules; dispatchTick advances. markRecovered halts further steps. metricsForCampaign returns recovery rate + avg time-to-purchase. Migration `0196`. · *`inventoryAudits` primitive — periodic full-inventory audits* — Kinds: full / quarterly / spot. Scopes: all / category / vendor / location. recordScanLine + markRecount + finalizeAudit. Variance write-through composes inventoryLocations.adjustStock with `inventory-audit:<slug>` reason. compareToPriorAudit returns per-(sku, location) deltas. Migration `0197`. · *`wishlistDigest` primitive — scheduled weekly / monthly wishlist email digests* — Per-customer enrollment + cadence. next_dispatch_at computed via Intl.DateTimeFormat with two-pass DST-fold refinement. composeDigest returns HTML + text. Suppressed customers keep cadence ETA on rails but no email fires. Migration `0198`. · *`eventLog` primitive — universal append-only application event stream* — Drop-silent on bad input. Severity: debug / info / warning / critical. query with HMAC cursor. tail + topKinds + metricsForKind + purgeOlderThan (exclude_critical opt-in). Distinct from analytics (customer events) and operatorAuditLog (operator mutations). Migration `0199`. · *`operatorHelpCenter` primitive — in-admin operator help articles* — audience_roles closed allow-list against operatorRoles.PERMISSIONS. searchSuggest title 3 / section 2 / body 1 weighted ranker. recordHelpfulVote dedup at UNIQUE(slug, operator_id). Migration `0200`. · *`categoryNavigation` primitive — hierarchical storefront category tree* — self-FK parent_slug + CHECK(slug != parent_slug). tree returns nested shape. breadcrumbsFor returns ancestor chain. move walks ancestor chain to refuse cycles (bounded MAX_TREE_DEPTH=16). reorderSiblings requires the complete member set. Migration `0201`. · *`lineGiftWrap` primitive — per-line gift wrap selection* — Distinct from giftOptions (one wrap for the whole order). UNIQUE(order_id, line_id) UPSERT. gift_message ≤ 500 chars + control-byte refusal; recipient_name ≤ 120. feeForOrder sums per-line wrap fees via injected giftOptions. renderPackingSlipLines HTML-escapes every operator field. Migration `0202`. · *`smartRestocking` primitive — EOQ + safety-stock recommendations* — Composes demandForecast + reorderThresholds + costLayers + vendors. recommendOrderQty returns EOQ formula `sqrt(2DS/H)` + service-level safety-stock + reorder point + cost estimate. Service levels: 0.90 / 0.95 / 0.99. definePolicy + applyPolicy assigns SKUs to policies. Migration `0203`. · *`taxRemittance` primitive — per-jurisdiction tax payment tracking* — Records remittances with payment_method enum (bank_transfer / credit_card / ach / wire / check). reconcileWithFiling returns variance. lateRemittances threshold + recordPenalty + metricsForJurisdiction on-time rate. Migration `0204`. · *`blogArticles` primitive — operator-published blog posts* — Author + tags + featured_product_ids + SEO meta + publish FSM (draft → published → archived → draft). Markdown render through b.template.escapeHtml + b.safeUrl. relatedArticles tag-overlap ranking. byAuthor + popularArticles + recordView. Migration `0189`.

- v0.0.74 (2026-05-22) — **`emailEngagementScore` primitive + republish 0.0.73.** 0.0.73 source landed on `main` but the npm publish workflow stopped on a smoke check — the email-engagement-score test was committed without its lib counterpart. 0.0.74 adds the missing `emailEngagementScore` primitive and republishes the full v0.0.73 surface so `npm install @blamejs/blamejs-shop@0.0.74` pulls every primitive cleanly. **Added:** *`emailEngagementScore` primitive — per-customer 0-100 email engagement grade* — `bShop.emailEngagementScore.create({ query?, emailCampaigns?, emailSuppressions? })` returns `{ recordEngagementEvent, getScore, recompute, recomputeAll, unengagedCustomers, metricsForBand, historyForCustomer }`. Six event types with calibrated weights (opened +5, clicked +15, unsubscribed -50, spam_reported -75, bounced -10, not_opened_in_window -3). Score starts at 50 baseline, clamped to [0, 100]. Four bands: unengaged (0-19) / lapsed (20-49) / engaged (50-74) / highly_engaged (75-100). Click implies open in the open_rate denominator (compensates for image-blocking clients). Monotonic per-customer `_resolveOccurredAt` to break same-millisecond ties. Migration `0187_email_engagement_score.sql`. **Fixed:** *Republish to npm — 0.0.73 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.73` resolves to a missing version because the publish workflow exited early when the CI smoke gate couldn't find `lib/email-engagement-score.js` (its test file was committed alongside v0.0.73 but the lib file itself was untracked at commit time). 0.0.74 ships the full surface so operators upgrading by version number get every primitive.

- v0.0.73 (2026-05-22) — **Twenty new primitives across operator ops, commerce, storefront, and compliance.** Twenty primitives ship together. Operator surfaces: marketingBudget, operatorSessions, operatorInbox, operatorActivityFeed, disputeResolution, shrinkageReport, demandForecast, fulfillmentSLA, salesTaxFilings, binLocations. Commerce: orderExchanges, searchRanking, shippingInsurance, cartRecovery, emailABTests, bannerABTests, referralLeaderboard. Storefront: pwaManifest, sidebarWidgets, announcementBar, stockReceipts, suggestionBox. Subscriptions: subscriptionAnalytics. Compliance: consentLedger. **Added:** *`marketingBudget` primitive — per-channel spend + ROAS* — Eleven channel kinds with per-channel currency lock. Integer-bps ROAS. UTC month budget reconciliation. Migration `0172`. · *`orderExchanges` primitive — different-item exchange FSM* — Six-state FSM with two-arm convergence. closeExchange requires both delivered_at and returned_at non-null. approveExchange opens an inventory hold pre-commit. Migration `0164`. · *`disputeResolution` primitive — chargeback lifecycle* — Four dispute kinds, seven evidence kinds, four outcomes. FSM open / submitted / lost / written_off. Migration `0173`. · *`operatorSessions` primitive — staff login with MFA + lockout* — 32-byte base64url bearer, hashed via namespaceHash. 8-hour TTL. Per-IP binding. Lockout after 5 failures in 15 minutes. Migration `0165`. · *`shippingInsurance` primitive — per-shipment insurance + claims* — Basis-point premium math. Insurance FSM active / cancelled / expired. Claim FSM filed / approved / denied. Four claim types. Migration `0166`. · *`pwaManifest` primitive — PWA manifest + service-worker config* — Display + orientation enums. Byte-stable JSON output via sorted-key serialise. URLs gated via b.safeUrl. Migration `0168`. · *`searchRanking` primitive — operator-tunable search reranker* — Boost/demote weights + manual pins per query. CTR + conversion math. Session ids hashed via namespaceHash. Migration `0167`. · *`emailABTests` primitive — subject + body A/B with Wilson CI* — Deterministic SHA3-512 assignment with 100k-bucket weight mapping. Sticky via the existing sent ledger row. Migration `0169`. · *`operatorInbox` primitive — in-admin notification feed* — Severity enum info / warning / urgent / critical. Exactly-one-of operator_id / role addressing. HMAC cursor pagination. Migration `0175`. · *`demandForecast` primitive — per-SKU demand prediction* — Four model kinds (SMA / WMA / exponential_smoothing / linear_regression). Band-coherence CHECKs. Migration `0179`. · *`stockReceipts` primitive — customer QR-scan delivery confirmation* — 32-byte token hashed at rest. Receipt FSM issued / scanned / completed / expired. Line FSM pending / received / damaged / partial. Migration `0177`. · *`bannerABTests` primitive — promo banner A/B with Wilson CI* — Deterministic per-session assignment. FSM running / paused / concluded / archived. Migration `0174`. · *`cartRecovery` primitive — abandon-cart email sequences* — Operator-defined step schedule. Composes cartAbandonment + email + emailSuppressions. markRecovered halts further steps. Migration `0171`. · *`sidebarWidgets` primitive — placement-targeted storefront blocks* — Nine widget kinds. Audience filter with optional customerSegments handle. Schedule-window gating + drop-silent counters. Migration `0176`. · *`consentLedger` primitive — append-only GDPR consent audit trail* — Nine consent kinds across cookies / marketing / data-sharing / data-processing. Two states (granted / withdrawn). Six sources. CSV / JSON audit export. Migration `0185`. · *`announcementBar` primitive — top-strip storefront message* — Four themes ranked urgency / promo / info / success. Schedule window + audience filter. Session-based dismissal dedup via UNIQUE(slug, session_id_hash). Migration `0180`. · *`shrinkageReport` primitive — loss-prevention dashboard* — Aggregates inventoryWriteoffs by reason / period / location / sku. Sample-stddev anomaly detection. Mixed-currency aggregation refusal. Migration `0170`. · *`subscriptionAnalytics` primitive — MRR / churn / cohort retention* — MRR cadence normalization across plan frequencies. Churn rate voluntary / involuntary / total. Cohort retention by month-zero. Migration `0178`. · *`operatorActivityFeed` primitive — staff-side timeline* — Aggregates operatorAuditLog + supportTickets + operatorInbox + operatorSessions. Cross-operator teamFeed. currentlyOnline 5-minute gate. Migration `0188`. · *`referralLeaderboard` primitive — top-referrer rankings + tier bonuses* — Reads from referrals primitive for ranking. Composes loyalty.earn for tier bonus payouts. UNIQUE(customer_id, period_label, tier) is the re-award gate. Migration `0182`. · *`fulfillmentSLA` primitive — order ship + deliver SLA tracking* — Per-priority policies (standard / expedited / overnight / same_day) with cutoff_local_time + timezone via Intl.DateTimeFormat. Severity bands minor / major / critical. Migration `0183`. · *`binLocations` primitive — warehouse bin / aisle / shelf assignments* — Auto-primary policy + pickPathSort by (aisle, shelf, level). Audit log with variance computation. Bin condition states clean / needs_audit / damaged / unusable. Migration `0186`. · *`salesTaxFilings` primitive — quarterly / monthly filings prep* — Aggregates orders + tax + taxRates + taxExempt into per-jurisdiction filings. FSM draft / computed / submitted / paid / amended. Banker's-rounded integer math on re-derive. Migration `0184`. · *`suggestionBox` primitive — customer feature-request box with up/downvote* — Categories: product_idea / feature_request / improvement / complaint / general. Status FSM open / under_consideration / planned / shipped / declined / duplicate. Vote dedup via UNIQUE(suggestion_id, session_id_hash). Migration `0181`.

- v0.0.72 (2026-05-22) — **`loyaltyEarnRules` primitive — per-action point earning rules.** `loyaltyEarnRules` defines HOW points are earned across eight trigger events: `per_dollar_spent`, `per_purchase`, `per_review`, `per_referral_redeemed`, `birthday`, `signup_bonus`, `first_purchase`, `abandoned_cart_recovered`. Distinct from the existing `loyalty` (ledger) and `tierBenefits` (perks) primitives. **Added:** *`loyaltyEarnRules` primitive — per-action point earning rules with optional per-event caps* — `bShop.loyaltyEarnRules.create({ query?, loyalty? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluateForEvent, awardForEvent, metricsForRule, applyBatch }`. Triggers: `per_dollar_spent / per_purchase / per_review / per_referral_redeemed / birthday / signup_bonus / first_purchase / abandoned_cart_recovered`. `evaluateForEvent` returns the points calculation (multiply + floor + zero-floor); `awardForEvent` composes the injected `loyalty.earn` handle and rolls back the audit row on ledger failure. Per-event cap (`max_per_event`) clips runaway awards. `customer_status_in` allowlist filters which customer tiers a rule applies to. UNIQUE(`rule_slug`, `customer_id`, `trigger_event_ref`) dedups re-submitted events. `applyBatch` runs multiple awards atomically with partial-failure reporting. Migration `0163_loyalty_earn_rules.sql`.

- v0.0.71 (2026-05-22) — **`splitShipments` — monotonic `proposed_at` so back-to-back plans sort newest-first.** Single-fix release. `splitShipments.planSplit` calls landing in the same wall-clock millisecond now get strictly-increasing `proposed_at` stamps via a process-local monotonic clock. Without the fix, `splitsForOrder(order_id)` returned the rows in nondeterministic order under burst-write conditions — CI runners hit the collision more reliably than local clocks. **Fixed:** *`splitShipments` — process-local monotonic `_now` so consecutive `planSplit` calls sort deterministically* — Two `planSplit` calls inside the same millisecond shared a `proposed_at` value, and the secondary tiebreaker (UUID v7 id) was scrambled by the intra-ms random suffix. The primitive now bumps `_now()` by 1ms when `Date.now()` returns a stale value, so `splitsForOrder` reliably returns newest-first under any insert burstiness. Same fix pattern as `orderNotes` / `productBulkOps` / `cookieConsent` / `liveChat` / `sms-dispatcher` / others — applies cleanly to any primitive where `Date.now()` precision drives sort order.

- v0.0.70 (2026-05-22) — **Six new primitives: click and collect, customer surveys, email templates, knowledge base, pixel events, sitemap generator.** Six primitives ship in one release covering store pickup (`clickAndCollect`), customer feedback (`customerSurveys`), operator-editable transactional email templates (`emailTemplates`), self-serve help center (`knowledgeBase`), server-side conversion pixel tracking (`pixelEvents`), and storefront sitemap generation (`sitemapGenerator`). **Added:** *`clickAndCollect` primitive — buy-online-pickup-in-store workflow* — `bShop.clickAndCollect.create({ query?, order?, inventoryLocations?, notifications? })` returns `{ definePickupLocation, availableLocations, scheduleAtLocation, markReadyForPickup, markPickedUp, markNoShow, pickupsForLocation, customerSchedules }`. Five-state FSM: scheduled → ready → picked_up + no_show + cancelled terminals. Capacity gated on one-hour buckets; lead-time gate per location. `markReadyForPickup` enqueues a pickup-ready notification through the injected handle (drop-silent on failure so a notifications outage can't roll back the hold shelf). `markPickedUp` drives the parent order to delivered via `order.transition(id, 'mark_delivered')` (swallows fsm/illegal-transition for idempotency). Signature hashed via `namespaceHash('click-and-collect-signature', raw)`. Migration `0126_click_and_collect.sql`. · *`customerSurveys` primitive — post-purchase NPS / CSAT / CES surveys* — `bShop.customerSurveys.create({ query? })` returns `{ defineSurvey, getSurvey, archiveSurvey, issueInvitation, getInvitation, invitationsForCustomer, submitResponse, responsesForSurvey, rollup, closeInvitation, cleanupExpired }`. Kinds: nps / csat / ces / custom. Trigger events: after_delivery / after_support_close / after_refund / manual. NPS / CSAT / CES enforce primary-question shape (`max === 10 / 5 / 7` respectively). NPS rollup = `round(%promoters - %detractors)`, CSAT = top-2-box positive_pct + mean, CES = mean + agree_pct. Invitation tokens 32-byte base64url plaintext returned exactly once, stored only as SHA3-512 namespaceHash. Migration `0128_customer_surveys.sql`. · *`emailTemplates` primitive — operator-editable transactional email templates* — `bShop.emailTemplates.create({ query? })` returns `{ defineTemplate, getTemplate, listTemplates, updateTemplate, publishVersion, archiveTemplate, renderTemplate, versionsFor, validateVariables }`. Closed 11-kind enum (order_confirmation / order_shipped / order_delivered / order_refunded / password_reset / account_verification / abandoned_cart / wishlist_discount / review_request / welcome / generic). `{{var_name}}` substitution composes `b.template.escapeHtml`; `{{var_name|raw}}` slots require schema vouching at definition time. Locale fallback to `en`. Migration `0125_email_templates.sql`. · *`knowledgeBase` primitive — self-serve customer help center with search ranking* — `bShop.knowledgeBase.create({ query?, defaultLocale?, cursorSecret? })` returns `{ defineArticle, getArticle, listArticles, updateArticle, publishArticle, unpublishArticle, archiveArticle, recordView, recordVote, voteAggregateForArticle, popularArticles, searchSuggest }`. Locale fallback chain. `searchSuggest` ranks by title 3 + tag 2 + body 1 weighted score; archived + unpublished excluded. Vote dedup at the `UNIQUE(slug, session_id_hash)` index. In-process markdown subset composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only + /-rooted internal). Session ids hashed via `namespaceHash` under `kb-view-session` / `kb-vote-session`. Migration `0162_knowledge_base.sql`. · *`pixelEvents` primitive — server-side conversion-tracking pixel / event API* — `bShop.pixelEvents.create({ query? })` returns `{ registerProvider, recordEvent, dispatchTick, markDispatched, markFailed, eventsForOrder, dispatchedInPeriod, failedEvents, metricsForProvider }`. Provider enum: meta_capi / google_ec / tiktok_events / pinterest_capi / snap_capi. Event names: purchase / add_to_cart / view_content / lead / complete_registration / search. Customer email + phone SHA-256-hashed (per provider spec — NOT namespaceHash; ad platforms accept SHA-256 of the normalised value). Five-step retry back-off on transient failures. Migration `0123_pixel_events.sql`. · *`sitemapGenerator` primitive — storefront sitemap.xml + sitemap-index.xml* — `bShop.sitemapGenerator.create({ query?, catalog?, collections?, storefrontPages?, custom? })` returns `{ defineSection, sections, archiveSection, validateOriginUrl, generate, recordGeneration, lastGeneration }`. Splits at 50,000 URLs or 50 MB serialized per chunk (whichever hits first). Path percent-encoding + XML escape on every emitted `<loc>`. Per-section sources: product / collection / storefront_page / custom. `validateOriginUrl` gates via `b.safeUrl` (https-only). Migration `0130_sitemap_generator.sql`.

- v0.0.69 (2026-05-22) — **Twelve new primitives + return-labels CI smoke fix.** Twelve primitives ship in one release plus a CI smoke fix that unblocks the npm publish workflow. The smoke fix bumps a too-tight 50ms `waitUntil` in `return-labels.test.js` to 5s so it survives the GitHub Actions runner's slower scheduling. New primitives cover orders (order ratings, packing slips, print queue), customers (wishlist sharing, customer activity feed, push notifications, order escalation), inventory (allocations, drop-ship forwarding, auto-replenishment), operator tooling (operator roles, clickstream events, damage photos). **Added:** *`orderRatings` primitive — per-order rating for shipping / packaging / recommend* — `bShop.orderRatings.create({ query? })` returns `{ submitRating, getRating, ratingsForCustomer, aggregateForPeriod, flagComment, responseToCustomer, topPositiveRatings, topNegativeRatings }`. Ratings 1..5 across three dimensions. UNIQUE(order_id) so one rating per order. Comment + operator response HTML-escaped on render. flagComment moderates abuse; responseToCustomer is the operator's public reply. Migration `0151_order_ratings.sql`. · *`wishlistSharing` primitive — share-a-wishlist links + group wishlists* — `bShop.wishlistSharing.create({ query?, wishlist? })` returns `{ createShareLink, revokeShareLink, viewShared, recordView, createGroupWishlist, joinGroupWishlist, leaveGroupWishlist, groupWishlistsForCustomer, listSharesForOwner, listGroupMembers }`. Tokens are 32-byte base64url plaintext returned once, hashed at rest via `namespaceHash` under per-domain namespaces. Privacy enum: public / unlisted / friends_only. Group wishlists let multiple customers co-author a list. Migration `0150_wishlist_sharing.sql`. · *`inventoryAllocations` primitive — soft-reserve holds for in-progress carts* — `bShop.inventoryAllocations.create({ query?, inventoryLocations? })` returns `{ holdForCart, releaseHold, releaseAllForCart, commitHold, extendHold, availableForSku, holdsForCart, cleanupExpiredHolds, metricsForSku }`. Holds prevent overselling during checkout. `availableForSku` returns committed - on_hold. `commitHold` composes `inventoryLocations.adjustStock` to commit the reservation as a real stock movement. TTL-bound; `cleanupExpiredHolds` walks expired rows and frees the inventory. Migration `0152_inventory_allocations.sql`. · *`dropshipForwarding` primitive — drop-ship vendor order-forwarding flow* — `bShop.dropshipForwarding.create({ query?, vendors?, orderTracking? })` returns `{ bindSkuToVendor, forwardOrder, markVendorAccepted, markVendorShipped, markVendorDelivered, markVendorFailed, markVendorReturned, getForwarding, forwardingsForOrder, pendingForwardings, metricsForVendor }`. Six-state FSM: queued / accepted / shipped / delivered / failed / returned. Composes `b.fsm` for transition validation. `forwardOrder` writes per-vendor forwarding rows. Migration `0154_dropship_forwarding.sql`. · *`damagePhotos` primitive — image attachments for damage / quality-control events* — `bShop.damagePhotos.create({ query? })` returns `{ recordPhoto, getPhoto, photosForSubject, archivePhoto, replacePhoto, metricsForKind, findDuplicatesBySha }`. Subject kinds: writeoff / return / quality_check / damaged_receipt / customer_complaint. SHA3-512 + size + content_type per upload. content_type allowlist: image/jpeg, image/png, image/webp, image/heic. `findDuplicatesBySha` returns prior uploads with the same digest (fraud detection signal). Migration `0159_damage_photos.sql`. · *`pushNotifications` primitive — APNs / FCM / Web Push outbound* — `bShop.pushNotifications.create({ query? })` returns `{ registerProvider, registerDevice, revokeDevice, recordOptIn, recordOptOut, isOptedIn, enqueueNotification, markDelivered, markFailed, dispatchTick, notificationsForCustomer, devicesForCustomer, metricsForProvider }`. Marketing channel uses opt-IN; transactional + alert use opt-OUT. Device class / provider kind compatibility enforced (ios↔apns, android↔fcm, web↔web_push, desktop↔fcm|web_push). Retry budget 5 with [1m, 5m, 30m, 2h, 12h] back-off. Migration `0148_push_notifications.sql`. · *`autoReplenish` primitive — auto-PO when reorder thresholds fire* — `bShop.autoReplenish.create({ query?, reorderThresholds?, purchaseOrders?, vendors? })` returns `{ definePolicy, tickReplenishment, getPolicy, listPolicies, archivePolicy, updatePolicy, replenishmentHistory, markPolicyTriggered }`. `tickReplenishment` finds reorder candidates via `reorderThresholds.scanAll` + groups by vendor + submits PO via `purchaseOrders.createDraft + submitToVendor`. Per-policy gates: min/max PO value, max_concurrent_open_pos cap, vendor-archived skip. Schedule: hourly / daily / weekly. Migration `0155_auto_replenish.sql`. · *`operatorRoles` primitive — staff-side RBAC* — `bShop.operatorRoles.create({ query?, operatorAuditLog? })` returns `{ defineRole, assignRoleToOperator, revokeRoleFromOperator, rolesForOperator, operatorsWithRole, hasPermission, listRoles, updateRole, archiveRole, listPermissions, permissionUsageLog, recordPermissionUse }`. 15-permission closed allow-list (orders.* / customers.* / catalog.* / inventory.write / vendors.manage / settings.write / billing.view / reports.read / users.* / support.handle). Multi-role union; expires_at honored. Migration `0157_operator_roles.sql`. · *`clickstream` primitive — server-side storefront analytics events* — `bShop.clickstream.create({ query? })` returns `{ recordPageView, recordEvent, sessionPath, topPages, topClicks, funnelAnalysis, bouncerate, dwellByPage, cleanupOlderThan }`. Tracks page views + clicks + form submits + scroll depth + dwell without third-party scripts. Event kinds: click / form_submit / form_abandon / video_play / video_complete / scroll_depth / dwell. Query strings + URL fragments stripped at write so PII in query params never reaches the table. Drop-silent on bad input. Migration `0161_clickstream.sql`. · *`customerActivity` primitive — per-customer aggregated activity timeline* — `bShop.customerActivity.create({ query?, cursorSecret?, order?, wishlist?, loyalty?, supportTickets?, reviews? })` returns `{ forCustomer, recentActivity, summarize, lastActivityAt, inactiveCustomers, purgeStaleCache }`. Aggregates events from order / wishlist / loyalty / support / reviews into one feed for the operator's customer-detail page. Missing peers skipped silently. `summarize` returns 30/90/365-day kind counts with cache hit/miss. Migration `0153_customer_activity_cache.sql`. · *`packingSlips` primitive — warehouse packing-slip rendering + print queue* — `bShop.packingSlips.create({ query?, order, giftOptions? })` returns `{ renderHtml, renderPdfPayload, recordPrint, printsForOrder, enqueueForLocation, dequeueForLocation, bulkRenderForLocation }`. Inlined Code-128 barcode SVG renderer (no external library dependency). HTML-escapes every operator + customer field. Gift options: hide_prices strips price/total columns; gift_message + recipient_name rendered inert against hostile input. Composite PK on the queue table as idempotency guard. Migration `0149_packing_slips.sql`. · *`printQueue` primitive — warehouse print job queue scheduler* — `bShop.printQueue.create({ query? })` returns `{ enqueueJob, claimJob, markComplete, markFailed, cancelJob, getJob, jobsForStation, pendingByKind, cleanupCompleted, stationActivity, dailyMetrics }`. Job kinds: packing_slip / shipping_label / invoice / packing_label / return_label / pickup_slip. `claimJob` returns next queued job (FIFO with priority tiebreak), flips `in_progress`. station_filter restricts which stations can claim a job. Retry budget on markFailed. Migration `0158_print_queue.sql`. **Fixed:** *`return-labels.test.js` — bumped a 50ms `waitUntil` timeout to 5s for CI runner scheduling* — GitHub Actions Ubuntu / macOS runners under contention sometimes need more than 50ms between `Date.now()` ticks to clear a monotonic-clock guard. The two `waitUntil` calls in `_listQueries` now use a 5000ms budget so the CI smoke gate doesn't drop the npm publish workflow. Local runs were always fast enough; only CI saw the failure.

- v0.0.68 (2026-05-22) — **Republish 0.0.67 + three new primitives: seller signup, tax cert renewals, order escalation.** 0.0.67 source landed on `main` but the npm publish workflow didn't reach the registry. 0.0.68 republishes the twelve primitives from 0.0.67 plus three new ones: `sellerSignup` (marketplace seller onboarding flow with KYC-style document workflow), `taxCertRenewals` (companion scheduler for `taxExempt` certificates expiring within a window), and `orderEscalation` (rule-driven operator routing for orders that need manual review). **Added:** *`sellerSignup` primitive — marketplace seller onboarding flow* — `bShop.sellerSignup.create({ query?, vendors? })` returns `{ submitApplication, requestDocument, recordDocumentUploaded, approveApplication, rejectApplication, requestRevisions, getApplication, listApplications, documentsForApplication }`. Document kinds: `w9 / w8ben / id / business_license / utility_bill / insurance / bank_statement / other`. Status FSM: `submitted → docs_pending → in_review → revisions_requested → approved | rejected | withdrawn`. `approveApplication` composes injected `vendors.registerVendor` to flip the application into a live vendor row. Email + phone + tax_id all hashed via `namespaceHash` under separate namespaces; raw values never persist. Migration `0146_seller_signup.sql`. · *`taxCertRenewals` primitive — companion scheduler for tax exemption certificate renewals* — `bShop.taxCertRenewals.create({ query?, taxExempt?, notifications? })` returns `{ defineSchedule, scanAndEnqueue, recordReminderSent, markEscalated, markRenewed, markExpired, certificationsDueSoon, metricsForSchedule, listSchedules, archiveSchedule, listRemindersForCert }`. Per-jurisdiction `lead_time_days` window. `scanAndEnqueue({ now })` walks `taxExempt` certs expiring in `[now, now + lead_time_days]` and queues reminders via the injected notifications handle. `markEscalated` fans out to operator after `escalate_after_days` if the customer hasn't acknowledged. Migration `0145_tax_cert_renewals.sql`. · *`orderEscalation` primitive — rule-driven operator routing for manual-review orders* — `bShop.orderEscalation.create({ query?, notifications?, orderTags? })` returns `{ defineRule, evaluateOrder, recordEscalation, resolveEscalation, markAsFalsePositive, openEscalations, escalationsForOrder, metricsForRule, listRules, updateRule, archiveRule }`. Rule conditions: `total_minor_min / country_in / prior_orders_max / fraud_score_min / refund_pending / payment_method_kind_in`. A rule fires only when every named condition matches the snapshot. `recordEscalation` fans out tag application via injected `orderTags` and notification dispatch via injected `notifications`. FSM: open → resolved | false_positive. Migration `0144_order_escalation.sql`. **Fixed:** *Republish to npm — 0.0.67 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.67` resolves to a missing version because the publish workflow exited early on a content-quality check in the release-notes file. 0.0.68 ships the identical surface as 0.0.67 plus the three new primitives above, so operators upgrading by version number get the full set.

- v0.0.67 (2026-05-22) — **Twelve new primitives: customer notes, order tags, customer risk profile, tier benefits, promo bundles, subscription gifts, reorder reminders, product Q&A, locale router, inventory writeoffs, robots config, refund automation.** Twelve primitives across CRM (customer notes, customer risk profile, tier benefits), commerce (order tags, promo bundles, subscription gifts, reorder reminders, product Q&A, refund automation), operations (inventory writeoffs), and storefront infrastructure (locale router, robots config). **Added:** *`customerNotes` primitive — operator-side CRM annotations attached to customer records* — `bShop.customerNotes.create({ query?, cursorSecret? })` returns `{ addNote, getNote, notesForCustomer, updateNote, archiveNote, unarchiveNote, pinNote, unpinNote, searchByTag, popularTags }`. Author enum: operator / system (customer-facing content belongs in portal). Kinds: general / preference / escalation / warning / billing. Body cap 8000, control-byte + zero-width refusals. HMAC cursors with orderKey separation prevent cross-listing replay attacks. Migration `0134_customer_notes.sql`. · *`orderTags` primitive — free-form operator tagging with rules engine* — `bShop.orderTags.create({ query?, cursorSecret? })` returns `{ defineTag, applyTag, removeTag, tagsForOrder, ordersWithTag, popularTags, defineRule, applyRulesToOrder, listTags, archiveTag, updateTag, historyForOrder }`. Soft-delete via partial UNIQUE index on `(order_id, tag_slug) WHERE removed_at IS NULL`. Rules engine matches conditions (total_minor / country / line_count / customer_segment) and applies tags automatically. Color CHECK gates malformed hex. Migration `0137_order_tags.sql`. · *`customerRiskProfile` primitive — long-running per-customer risk aggregation* — `bShop.customerRiskProfile.create({ query?, fraudScreen?, returns?, dunning?, creditLimits? })` returns `{ recordSignal, getProfile, tier, historyForCustomer, recompute, recomputeAll, topRiskCustomers, clearSignal }`. Signal kinds: chargeback / refund_request / fraud_score_high / chargeback_dispute_lost / late_payment / address_mismatch / device_diversity_high / refund_to_credit. Severity 1..10. Bands: low (0..9) / moderate (10..24) / high (25..49) / critical (50+). 90-day recent window; older signals count toward lifetime only. `clearSignal` is the operator override for false positives. Migration `0142_customer_risk_profile.sql`. · *`tierBenefits` primitive — per-loyalty-tier perks engine* — `bShop.tierBenefits.create({ query?, loyalty? })` returns `{ defineBenefit, updateBenefit, archiveBenefit, listBenefits, benefitsForTier, benefitsForCustomer, recordUsage, usageForBenefit, metricsForBenefit }`. Kinds: free_shipping / percent_off / early_access / priority_support / exclusive_access / birthday_bonus. Per-kind value validation. Conditions envelope (min_order / currency / region / time window) filters at resolution. `benefitsForCustomer` composes loyalty.tierForCustomer with `.balance(id).tier` fallback. Migration `0141_tier_benefits.sql`. · *`promoBundles` primitive — time-limited promotional bundles distinct from regular bundles* — `bShop.promoBundles.create({ query?, catalog? })` returns `{ defineBundle, getBundle, activeBundles, detectInCart, applyBundleToCart, recordRedemption, updateBundle, archiveBundle, metricsForBundle }`. Components canonicalized sorted-by-sku. `applyBundleToCart` is a pure transform. `recordRedemption` uses guarded UPDATE for cap-race + UNIQUE(slug, order_id) for idempotency. Mutable surface restricted to title/window/caps post-define; components/price/currency immutable. Migration `0140_promo_bundles.sql`. · *`subscriptionGifts` primitive — gift a subscription + owner-to-owner transfers* — `bShop.subscriptionGifts.create({ query?, subscriptions?, email? })` returns `{ purchaseGift, redeemGift, cancelGift, transferOwnership, getGift, giftsForGiver, transfersForSubscription, expiringGifts, cleanupExpired }`. Token: 32-byte uniform draw, base64url-rendered (43 chars), SHA3-512 hashed at rest under `subscription-gift-token`, plaintext returned exactly once. Recipient email hashed via guardEmail + namespaceHash. FSM pending → delivered → redeemed | expired | cancelled with race-loser detection on UPDATE rowCount. Migration `0135_subscription_gifts.sql`. · *`reorderReminders` primitive — "time to reorder" nudges for consumable products* — `bShop.reorderReminders.create({ query?, order?, notifications?, email? })` returns `{ defineReminderProfile, enrollCustomerSku, dispatchTick, recordSent, markFailed, remindersForCustomer, unsubscribeFromSku, metricsForProfile }`. Operator defines per-SKU reorder intervals; primitive enqueues at `last_order_at + interval_days`. Channels: email / sms / in_app via injected handles. `unsubscribeFromSku` stops future dispatches. Migration `0136_reorder_reminders.sql`. · *`productQA` primitive — customer Q&A on PDP with moderation queue* — `bShop.productQA.create({ query?, customers? })` returns `{ submitQuestion, submitAnswer, approveQuestion, rejectQuestion, approveAnswer, rejectAnswer, pinAnswer, voteUpAnswer, questionsForProduct, getQuestion, topAnswerForQuestion, metricsForProduct, cleanupRejected }`. Questions + answers moderated separately. Vote-up dedup via UNIQUE(answer_id, session_id_hash). Top-answer pinning floats one answer above sort. Email + session id namespace-hashed before write. Migration `0133_product_qa.sql`. · *`localeRouter` primitive — storefront locale resolution from request hints* — `bShop.localeRouter.create({ query?, customers?, geolocation? })` returns `{ defineLocale, definePolicy, resolveLocale, setCustomerLocale, clearCustomerLocale, getPolicy, listPolicies, archivePolicy, setActivePolicy, activePolicy, localePopularity, getLocale, listLocales, getCustomerLocale }`. Strategies: url_prefix / subdomain / cookie / accept_language_only / customer_preference. Customer pref wins across every strategy; URL/subdomain/cookie/Accept-Language fallback per active strategy. Partial-unique index enforces at-most-one active policy. Migration `0139_locale_router.sql`. · *`inventoryWriteoffs` primitive — damage / lost / shrinkage stock write-offs* — `bShop.inventoryWriteoffs.create({ query?, inventoryLocations?, costLayers? })` returns `{ recordWriteoff, getWriteoff, listWriteoffs, costImpactForPeriod, reverseWriteoff }`. Reasons: damaged / lost / shrinkage / expired / recall / sample / quality_control / theft. Debits stock via inventoryLocations.adjustStock; composes costLayers.recordReversal when injected. Mixed-currency aggregation refusal in `costImpactForPeriod`. Compensating rollback on costLayers refusal so the shelf never disagrees with the writeoff ledger. Migration `0138_inventory_writeoffs.sql`. · *`robotsConfig` primitive — operator-editable robots.txt rules* — `bShop.robotsConfig.create({ query? })` returns `{ defineRule, listRules, archiveRule, updateRule, addSitemap, removeSitemap, listSitemaps, setHostDirective, getHostDirective, render, predefinedTemplates, applyTemplate }`. Predefined templates: `block_ai_crawlers` (GPTBot, ClaudeBot, CCBot, Google-Extended, Bytespider, Amazonbot, FacebookBot, and other major AI training crawlers), `block_all`, `open_all`, `standard_with_admin_disallow`. Sitemap URLs validated via b.safeUrl (https-only). `host_directive` table locked to single row via PK = 1. Migration `0147_robots_config.sql`. · *`refundAutomation` primitive — auto-refund eligibility + execution rules* — `bShop.refundAutomation.create({ query?, refundPolicy?, returns?, payment?, customerRiskProfile? })` returns `{ defineAutoRule, evaluateForRefundRequest, executeAutoRefund, markManualOverride, metricsForRule, listRules, updateRule, archiveRule }`. Rules: max_amount_minor, max_refunds_per_customer_year, requires_low_risk (via customerRiskProfile.bandFor), eligible_reasons, currency_in_set. `executeAutoRefund` composes payment.refund. `markManualOverride` flips a manual_review row to auto_approved / declined. Per-factory monotonic clock guarantees strictly-increasing decided_at. Migration `0143_refund_automation.sql`.

- v0.0.66 (2026-05-22) — **Ten new primitives: site redirects, cost layers, payment retries, pick lists, preorder, discount allocation, currency rounding, credit limits, theme assets, business hours.** Ten primitives ship in one release covering warehouse operations (pick lists, FIFO/LIFO inventory cost layers), commerce (preorder, currency rounding rules, discount allocation breakdown), B2B (credit limits with net-30 terms + aging), payment recovery (payment retries with failure-code-aware policy), and storefront infrastructure (site redirects, theme assets registry, business-hours calculator). **Added:** *`siteRedirects` primitive — operator-defined 301/302/307/308 redirects* — `bShop.siteRedirects.create({ query? })` returns `{ defineRedirect, resolveForPath, recordHit, listRedirects, getRedirect, updateRedirect, archiveRedirect, unarchiveRedirect, topHits, cleanupExpired }`. Match kinds: exact / prefix / regex. Regex patterns refused if they carry backreferences (`\1`-`\9`) or lookahead/lookbehind to keep catastrophic-backtracking out of the live table. `resolveForPath` walks exact > prefix (longest source_path wins, slug ASC tiebreak) > regex (slug ASC). `target_url` accepts /-rooted internal paths OR full https:// via `b.safeUrl`. Migration `0119_site_redirects.sql`. · *`costLayers` primitive — FIFO / LIFO / weighted-average inventory cost tracking* — `bShop.costLayers.create({ query?, catalog? })` returns `{ setMethod, getMethod, recordReceipt, consumeForSale, recordReversal, currentLayers, cogsForOrder, cogsForPeriod }`. Method enum: fifo / lifo / weighted_average. FIFO consumes oldest layers first; LIFO newest; weighted_average computes the mean of remaining layers. Each receipt adds a cost layer with `quantity_remaining` + `unit_cost_minor`. `consumeForSale` returns `{ consumed_layers: [...], total_cogs_minor, currency }` and refuses on insufficient on-hand. `recordReversal` restores stock on returns by adding back to a layer. Per-SKU strict-monotonic `occurred_at` clock so same-millisecond receipts can't tie on the FIFO/LIFO ordering key. Migration `0121_cost_layers.sql`. · *`paymentRetries` primitive — failure-code-aware retry orchestration for one-time payments* — `bShop.paymentRetries.create({ query?, payment? })` returns `{ defineRetryPolicy, enrollFailure, tickRetries, recordRetryOutcome, unenrollPayment, statusForPayment, historyForPayment, policiesForFailureCode, metricsForPolicy }`. Distinct from `dunning` (subscriptions only). Failure-code-aware: `insufficient_funds` → wait 24h + retry; `card_declined` → wait 7d + retry once; `invalid_card` → terminal-at-enrollment. `tickRetries` walks `next_retry_at <= now` rows and dispatches via composed payment.retry. `terminal_after_attempts` exhausts an enrollment + flips `abandoned` status. Migration `0120_payment_retries.sql`. · *`pickLists` primitive — warehouse pick-list workflow* — `bShop.pickLists.create({ query?, order, orderTracking?, inventoryLocations? })` returns `{ generateList, getList, listLists, confirmLine, markListComplete, cancelList, discrepanciesFor }`. Generated from N open orders for a given warehouse, sorted by aisle/sku/priority/order_id (materialized into a `sequence_number` column so picker walk order is durable across reads). Optional `inventoryLocations.binForSku` probe for aisle position. `markListComplete` fans out shipment creation via composed `orderTracking.createShipment` per-order. Migration `0118_pick_lists.sql`. · *`preorder` primitive — pre-launch reservations distinct from backorder* — `bShop.preorder.create({ query?, catalog?, order? })` returns `{ defineCampaign, reserve, getReservation, reservationsForCustomer, cancelReservation, convertReservationToOrder, launchCampaign, closeCampaign, availability, getCampaign }`. Backorder = sku exists but out-of-stock; preorder = sku not yet released. `defineCampaign({ slug, sku, launch_at, charge_at?, max_units_available?, deposit_minor?, full_price_minor })`. `launchCampaign` triggers auto-conversion of all active reservations via composed `order.createFromCart`. Cancelled reservations free capacity for re-reservation. Migration `0124_preorder.sql`. · *`discountAllocation` primitive — per-line allocation of order-level discounts* — `bShop.discountAllocation.create({ query })` returns `{ allocate, recordAllocation, allocationsForOrder, reverseAllocation, metricsForKind }`. Order has $20 off the total — primitive distributes across lines for accounting + refund precision. Kinds: proportional / equal / by_subtotal / by_quantity. Half-even rounding via `b.money.fromMinorUnits(...).multiply([num, den])`; remainder lands deterministically on highest-subtotal line. `reverseAllocation` produces per-line refund amounts that re-sum to the original total. Migration `0129_discount_allocation.sql`. · *`currencyRounding` primitive — per-currency display rounding rules* — `bShop.currencyRounding.create({ query? })` returns `{ defineRule, getRule, listRules, roundFor, updateRule, archiveRule, historyForCurrency, round }`. Distinct from `currencyDisplay` (FX rate cache) — this is the rounding behavior applied at checkout. CHF rounds to nearest 0.05; SEK to nearest 0.10; JPY whole units. Modes: half_up / half_even / half_down / ceiling / floor. `applies_to` enum: display_only / cart_total / line_total / all. Pure `_round(amount, step, mode)` exported as `currencyRounding.round` for direct composition without the factory. Migration `0132_currency_rounding.sql`. · *`creditLimits` primitive — B2B credit accounts with net-30 / net-60 terms + aging report* — `bShop.creditLimits.create({ query?, customers? })` returns `{ defineAccount, getAccount, listAccounts, updateAccount, suspendAccount, reinstateAccount, chargeOrder, releaseHold, recordPayment, availableCredit, outstandingBalance, agingReport }`. `chargeOrder` checks available credit, refuses on insufficient (no row written). `releaseHold` restores credit on cancel. `agingReport({ customer_id })` returns balance by days-past-due bucket (current / 30d / 60d / 90d+). Payments + releases FIFO-settle against oldest charges. FSM: active / suspended / closed. Migration `0122_credit_limits.sql`. · *`themeAssets` primitive — operator-uploaded theme asset registry* — `bShop.themeAssets.create({ query? })` returns `{ registerAsset, getAsset, assetsForTheme, assetsByKind, updateAsset, archiveAsset, assignToTheme, recordImpression, metricsForAsset, cleanupOrphans }`. Asset kinds: font / logo / hero_image / favicon / banner_image / og_image / icon / custom. Content-addressed via `sha3_512` digest (validated as lowercase 128-char hex). `source_url` gated via `b.safeUrl` (https-only + /-rooted internal). `cleanupOrphans({ days_idle })` archives assets not assigned to any active theme. Migration `0131_theme_assets.sql`. · *`businessHours` primitive — operator-defined business-hour windows with DST-correct math* — `bShop.businessHours.create({ query? })` returns `{ defineSchedule, isOpenAt, nextOpenAt, nextCloseAt, weekSummary, addException, removeException, addHoliday, listSchedules, archiveSchedule }`. Per-day open/close times in IANA timezone, plus per-date exceptions + holidays. `Intl.DateTimeFormat` for tz-correct calendar math (DST-correct via Intl, no manual offset arithmetic). Two-pass `_wallClockToEpochMs` algorithm converges across DST folds. `nextOpenAt` skips holidays + closed days. Migration `0127_business_hours.sql`.

- v0.0.65 (2026-05-22) — **Twenty new primitives: address validation, auto discount, captcha gate, catalog drafts, cookie consent, customer roles, cycle counting, delivery estimate, email warmup, metered usage, price display, product bulk ops, purchase orders, quotes, recommendations, reorder thresholds, shipping zones, split shipments, trust badges, webhook receiver.** Twenty primitives ship in one release. Covers per-customer self-serve flows (cookie consent, captcha gate, address validation), operator-side catalog tooling (drafts, bulk ops, reorder thresholds, purchase orders, cycle counting, delivery estimate, shipping zones), commerce primitives (auto discount, quotes, recommendations, price display, split shipments, metered usage, trust badges), and integrations (webhook receiver, email warmup, customer roles). **Added:** *`addressValidation` primitive — cache + lookup for address-validation API results* — `bShop.addressValidation.create({ query? })` returns `{ recordValidation, lookupCached, signatureFor, recordSuggestion, lookupSuggestions, cleanupExpired, metricsForSource, validationsForOrder }`. Input signature derived via `namespaceHash('address-validation-input', canonical(input))` so the same address normalizes to one cache row. Source enum: `usps / smarty / lob / google / melissa / manual`. Classification enum: `residential / commercial / po_box / military / unknown`. Migration `0115_address_validation.sql`. · *`autoDiscount` primitive — automatic discounts without coupon codes* — `bShop.autoDiscount.create({ query?, catalog?, customerSegments? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluate, recordApplication, metricsForRule }`. Trigger kinds: `cart_total_min / item_count_min / sku_purchase`. Value kinds: `percent_off / amount_off_total / amount_off_each / free_shipping / bogo`. Priority + max_redemptions + customer segment gating. Migration `0107_auto_discount.sql`. · *`captchaGate` primitive — CAPTCHA verification at high-risk entrypoints* — `bShop.captchaGate.create({ query? })` returns `{ registerProvider, verifyToken, recordOutcome, metricsForProvider, ... }`. Provider enum: `turnstile / hcaptcha / recaptcha_v2 / recaptcha_v3`. reCAPTCHA v3 gets a threshold-score floor (basis-points). Secret hashed via `namespaceHash('captcha-secret', ...)`; session id hashed before write. Provider-callback-driven verify; operator's worker calls the actual provider endpoint. Migration `0114_captcha_gate.sql`. · *`catalogDrafts` primitive — staging workflow for catalog mutations* — `bShop.catalogDrafts.create({ query?, catalog })` returns `{ openDraft, stageChange, listChanges, removeChange, previewMerged, publishDraft, cancelDraft, rollbackDraft, listDrafts, historyForSku }`. Stage N changes against catalog (create / update / archive products + variants + prices + tags + inventory); publish atomically. 7-day rollback window. Pre-flight validates every change against the live catalog before writing anything. Migration `0112_catalog_drafts.sql`. · *`cookieConsent` primitive — GDPR / ePrivacy consent management* — `bShop.cookieConsent.create({ query? })` returns `{ recordConsent, getConsentFor, withdrawConsent, categoryAllowed, metricsForBanner, cleanupOlderThan, registerPolicyVersion }`. Categories: strictly-necessary (always on) + functional + analytics + marketing + preferences. DNT / Sec-GPC headers force implicit deny on marketing + analytics regardless of recorded consent. Policy-version bump flags older session consents for re-prompt. Session id hashed before write. Migration `0103_cookie_consent.sql`. · *`customerRoles` primitive — B2B company-account roles + capabilities* — `bShop.customerRoles.create({ query?, customers? })` returns `{ defineRole, assignRole, unassignRole, rolesForEmployee, employeesForCompany, hasCapability, recordOrderApproval, listRoles, updateRole, archiveRole }`. Capabilities: `can_view_orders / can_place_order / can_approve_order / can_manage_users / can_view_pricing / can_apply_payment_terms / can_request_quote / can_view_invoices`. UNIQUE(company, employee) so an employee carries one role per company. Migration `0101_customer_roles.sql`. · *`cycleCounting` primitive — physical inventory cycle counts* — `bShop.cycleCounting.create({ query?, catalog, inventoryLocations? })` returns `{ defineCount, worksheetFor, recordCount, finalizeCount, cancelCount, discrepanciesFor, listCounts, historyForSku, getCount }`. Kinds: `rotating / abc / full`. `finalizeCount({ apply_adjustments })` writes adjustments through `inventoryLocations.adjustStock` with `reason='cycle-count:<slug>'` so the audit row carries the count slug. Migration `0108_cycle_counting.sql`. · *`deliveryEstimate` primitive — PDP + cart delivery-window calculator* — `bShop.deliveryEstimate.create({ query?, inventoryLocations?, shippingZones? })` returns `{ defineCarrierTransit, defineCutoff, defineHoliday, definePostalZone, estimate, estimateForCart, listTransits, listCutoffs, listHolidays, archiveTransit, archiveHoliday }`. Calendar math is Intl-DateTimeFormat-driven (DST-correct); weekend + region-holiday skips applied separately to origin ship-by + destination delivery-by. Migration `0117_delivery_estimate.sql`. · *`emailWarmup` primitive — gradual SMTP IP/domain warmup* — `bShop.emailWarmup.create({ query?, now? })` returns `{ defineSchedule, canSend, recordSends, recordEngagement, currentDay, pauseSchedule, resumeSchedule, archiveSchedule, listSchedules, getSchedule, metricsForSchedule }`. Daily targets array (Day 1: 50, Day 2: 100, etc.) cap outbound volume to build sender reputation. Day-index math computed against UTC midnight of `start_date`. Migration `0116_email_warmup.sql`. · *`meteredUsage` primitive — usage-based subscription billing* — `bShop.meteredUsage.create({ query?, subscriptions?, subscriptionBilling? })` returns `{ defineMeter, recordUsage, usageForPeriod, periodSummary, recordPeriodInvoice, listMeters, updateMeter, archiveMeter }`. Tier schedule for tiered pricing (first 1000 free, next 10000 at $0.001, ...). `recordUsage` idempotency_key dedups re-submitted events. `recordPeriodInvoice` queues invoice line via subscriptionBilling. Migration `0095_metered_usage.sql`. · *`priceDisplay` primitive — per-region tax-included / tax-excluded display modes* — `bShop.priceDisplay.create({ query?, tax?, taxRates?, geolocation? })` returns `{ defineRule, getModeFor, formatPrice, defineCustomerOverride, clearCustomerOverride, customerOverride, listRules, updateRule, archiveRule }`. Resolution priority: customer-override > customer_status_in > country default. Compose `tax.calculateInclusive / calculateExclusive` for the breakdown. Migration `0097_price_display.sql`. · *`productBulkOps` primitive — bulk product mutations with pre-flight + audit* — `bShop.productBulkOps.create({ query?, catalog, maxBulkRows? })` returns `{ bulkSetPrice, bulkAdjustPrice, bulkArchive, bulkUnarchive, bulkAddTag, bulkRemoveTag, bulkSetInventory, previewFilter, auditTrail, categories, tags }`. Filter `{ skus?, vendor_slug?, category?, tag_any?, tag_all? }`. Pre-flight cap default 1000, ceiling 10000. Half-up rounding on percent adjustments; floored at 0. Process-local monotonic `_now` so back-to-back audit rows sort newest-first reliably. Migration `0104_product_bulk_ops.sql`. · *`purchaseOrders` primitive — operator-facing POs to vendors* — `bShop.purchaseOrders.create({ query?, vendors?, inventoryReceive? })` returns `{ createDraft, submitToVendor, confirmByVendor, recordPartialReceipt, closePO, cancelPO, getPO, listPOs, linesForPO, update }`. FSM `draft → submitted → confirmed → partially_received → received → closed`. `recordPartialReceipt` composes `inventoryReceive.draft + apply` for restock. `closePO` refuses unless all lines fully received. Migration `0099_purchase_orders.sql`. · *`quotes` primitive — B2B RFQ / quote flow* — `bShop.quotes.create({ query?, cart?, order? })` returns `{ requestQuote, respondToQuote, customerAccept, customerReject, cancelQuote, convertToOrder, getQuote, quotesForCustomer, pendingResponse, listExpired, markExpired }`. FSM `requested → responded → accepted → converted` with `rejected / expired / cancelled` terminal branches. `convertToOrder` composes `order.createFromCart` using the quoted prices. Accepts either `lines` or `cart_id` (via cart aggregator). Migration `0102_quotes.sql`. · *`recommendations` primitive — storefront product recommendation engine* — `bShop.recommendations.create({ query?, catalog, analytics?, recentlyViewed? })` returns `{ recommendForProduct, recommendForCart, recommendForCustomer, recommendForCategory, setOverride, removeOverride, listOverrides, recordImpression, recordClick, recordConversion, metricsForKind }`. Override layer reads first then falls through co-purchase → category-popular → random-in-stock. Session id namespace-hashed before write. Migration `0105_recommendations.sql`. · *`reorderThresholds` primitive — per-SKU + per-location reorder thresholds + PO suggestions* — `bShop.reorderThresholds.create({ query?, catalog, inventoryLocations?, vendors? })` returns `{ defineThreshold, evaluate, scanAll, proposePurchaseOrder, recordVelocity, updateThreshold, archiveThreshold, listThresholds }`. Velocity-driven suggested_qty = `gap + ceil(units_per_day * lead_time_days)`. Partial-UNIQUE on `(sku, location_code)` for active rows. Migration `0098_reorder_thresholds.sql`. · *`shippingZones` primitive — operator-defined shipping zones + rate tables* — `bShop.shippingZones.create({ query? })` returns `{ defineZone, getZone, listZones, updateZone, archiveZone, rateFor, zoneForDestination }`. Region match: country-only or country + region. `rateFor` half-open `[min_weight, max_weight)` + `[min_order, max_order)` bucketing across rate rows; returns all matches sorted by rate ascending. Migration `0106_shipping_zones.sql`. · *`splitShipments` primitive — one-order N-shipment planning* — `bShop.splitShipments.create({ query?, order, orderTracking, backorder?, inventoryLocations? })` returns `{ planSplit, executeSplit, mergeShipments, splitsForOrder, recommendStrategy }`. Strategies: `availability / location / vendor / manual`. `executeSplit` writes shipment rows via `orderTracking.createShipment`. `mergeShipments` combines source shipments into a target. Migration `0096_split_shipments.sql`. · *`trustBadges` primitive — storefront trust seals + certifications* — `bShop.trustBadges.create({ query? })` returns `{ defineBadge, activeForPlacement, listAll, getBadge, updateBadge, archiveBadge, renderHtml, recordImpression, recordClick, impressionCount, clickCount }`. Placements: header / footer / pdp / cart_review / checkout / order_confirmation. SVG payload sanitized via `b.guardSvg`; link_url through `b.safeUrl`. Multiple badges can stack at one placement, sorted priority-DESC. Migration `0111_trust_badges.sql`. · *`webhookReceiver` primitive — inbound HMAC-signed webhook acceptor* — `bShop.webhookReceiver.create({ query?, now? })` returns `{ defineSource, verifyAndPersist, markProcessed, markFailed, unprocessedEvents, eventsForSource, getEvent, purgeOlderThan, rotateSecret, archiveSource, getSource, listSources }`. HMAC-SHA-256 verification + replay-window timestamps + idempotency_key dedup. Rotation carries a 24h grace window where deliveries signed under the old secret still verify. Plaintext secret returned once at `defineSource` / `rotateSecret`; storage holds only the namespaceHash digest. Migration `0110_webhook_receiver.sql`. **Fixed:** *`productBulkOps` — monotonic `_now` so back-to-back audit rows sort newest-first reliably* — Same Date.now() resolution issue that affected several earlier primitives — replaced with a process-local monotonic clock that bumps by 1ms on collision. The audit-trail `ORDER BY occurred_at DESC, id DESC` listing now returns rows in the correct insertion order even under same-millisecond bulk operations.

- v0.0.64 (2026-05-22) — **Two new primitives: compliance export + live chat.** `complianceExport` is the GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle. `liveChat` is the real-time customer-service queue with operator assignment and per-session messages. **Added:** *`complianceExport` primitive — GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle* — `bShop.complianceExport.create({ query?, customers, order?, orderNotes?, subscriptions?, addresses?, paymentMethods?, supportTickets?, loyalty? })` returns `{ requestExport, requestDeletion, getRequest, listRequests, fulfillRequest, dispatchExport, processDeletion, dismissRequest, auditForCustomer }`. Composes injected per-domain readers via a `forCustomerExport(customer_id)` / `forCustomerDeletion(customer_id, { dry_run })` contract; the primitive owns the request row, the scope/section filtering, and the dry-run-vs-wet posture. Scope enum: `full / orders_only / identity_only`. Jurisdiction enum: `gdpr / ccpa / lgpd / other`. Status FSM: `received / processing / fulfilled / delivered / dismissed`. Migration `0109_compliance_export.sql`. · *`liveChat` primitive — real-time customer-service queue* — `bShop.liveChat.create({ query? })` returns `{ openSession, enqueueSession, assignToOperator, recordMessage, closeSession, getSession, messagesForSession, operatorQueue, waitingQueue, operatorWorkload, markOperatorAvailable, markOperatorAway, cleanupAbandoned }`. Six-state FSM: queued / assigned / active / waiting / closed / abandoned. Visitor session id + email hashed via `namespaceHash` under `live-chat-visitor` / `live-chat-email` namespaces; raw values never persist. Process-local monotonic `_now` so back-to-back operator assignments and message inserts paginate stably. `cleanupAbandoned({ idle_minutes })` reaps idle sessions. Migration `0113_live_chat.sql`.

- v0.0.63 (2026-05-22) — **Three new primitives: invoice renderer, store credit, error log.** Three primitives ship in this release. `invoiceRenderer` produces operator + customer-facing formal invoices (HTML for PDF conversion + plain text + sequential invoice numbers). `storeCredit` is the per-customer account-bound credit wallet, distinct from gift cards (no code). `errorLog` is the operator-side HTTP error event log with top-404 / top-5xx / slow-render dashboards. **Added:** *`invoiceRenderer` primitive — formal accounting invoices with sequential numbers* — `bShop.invoiceRenderer.create({ query?, order })` returns `{ nextInvoiceNumber, renderHtml, recordInvoice, invoicesForOrder, getInvoiceByNumber }`. `nextInvoiceNumber({ series? })` returns sequential `INV-YYYY-NNNNNN`-shaped id with per-series counter. `renderHtml({ order_id, locale?, series?, due_days? })` returns the full HTML invoice with operator + customer addresses, line items, tax breakdown, totals, payment terms, due date. All operator + customer input fields HTML-escaped via `b.template.escapeHtml`. `recordInvoice` writes the audit row carrying `html_size` + `sha3_512` digest. Migration `0093_invoice_renderer.sql` (`invoice_renderings` + `invoice_sequence` tables). · *`storeCredit` primitive — per-customer account-bound credit wallet* — `bShop.storeCredit.create({ query? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, expiringWithin, bulkBalance, cleanupExpired }`. Distinct from `giftCardLedger` — store credit is account-bound, no code. `credit` source enum: `refund / goodwill / promotional / manual / loyalty_redemption`. `debit` refuses overdraft. `balance_after_minor` denormalized per row so current balance is O(1). `cleanupExpired({ now })` scheduler-callable: walks expired rows + adds offsetting expire entries. `expiringWithin({ customer_id, days })` lists balance set to expire so the storefront can prompt the customer. Migration `0094_store_credit.sql`. · *`errorLog` primitive — HTTP error event log with operator dashboards* — `bShop.errorLog.create({ query? })` returns `{ recordError, top404Paths, top5xxErrors, slowRenders, errorById, byStatus, metrics }`. Drop-silent on bad input — hot-path observability sink; throwing here would crash the request that triggered the log entry. Session id always hashed via `namespaceHash('error-log-session', ...)` before write. `top404Paths({ from, to, limit })` and `top5xxErrors({ from, to, limit })` power operator dead-link cleanup + upstream-health dashboards. `slowRenders({ from, to, limit, p99_threshold_ms? })` surfaces requests over the operator-configured p99 budget. `metrics` returns counts + p50 / p95 / p99 response time percentiles. Migration `0100_error_log.sql`.

- v0.0.62 (2026-05-22) — **Ten new primitives: plan changes, vendors, loyalty redemption, gift registry, geolocation, stock transfers, SMS dispatcher, email campaigns, storefront dashboards, refund policy.** Ten primitives ship in one release covering subscription proration, multi-vendor marketplace foundation, loyalty redemption catalog, wedding/baby/housewarming registries, country resolution from request hints, audited multi-step stock movements, SMS opt-in / dispatch / STOP-keyword handling, bulk email campaign scheduling, operator dashboard composition, and refund eligibility rules. **Added:** *`planChanges` primitive — subscription plan upgrade / downgrade with proration* — `bShop.planChanges.create({ query?, subscriptions, subscriptionBilling? })` returns `{ proposeChange, executeChange, cancelPendingChange, pendingChangeFor, historyForSubscription, applyScheduledChanges, prorate }`. `proposeChange({ subscription_id, new_plan_id, change_at? })` returns `{ proration_credit_minor, first_charge_minor, currency, effective_at, change_kind, from_plan_id, to_plan_id }`. `change_kind` derives off the clock: `change_at >= current_period_end` → `next_billing_cycle` with zero proration; otherwise → `immediate` with floored `(amount * remainingMs / periodMs)` math on both sides. `executeChange` writes the ledger row, flips `subscriptions.plan_id` when the effective clock is now-or-past or queues for the scheduler. Queues a net proration invoice through `subscriptionBilling.recordInvoice` when injected. Module-level `planChanges.prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt)` is exposed for callers that want the pure math without instantiating the factory. Migration `0083_plan_changes.sql`. · *`vendors` primitive — multi-vendor marketplace foundation* — `bShop.vendors.create({ query? })` returns `{ registerVendor, getVendor, vendorBySlug, listVendors, updateVendor, pauseVendor, reinstateVendor, archiveVendor, assignSku, unassignSku, vendorForSku, skusForVendor, recordCommission, payoutsDue }`. Slug is the public PK, immutable post-registration so `vendor_skus` + `vendor_commissions` rows never orphan. `contact_email` hashed via `namespaceHash('vendor-contact-email', lowercased)` — raw email never persists. FSM: `active ↔ paused`, both → `archived` (terminal). `assignSku` enforces single-vendor SKU ownership via `vendor_skus.sku UNIQUE`. `recordCommission` is idempotent on `(vendor_slug, order_id)` so the same multi-vendor `order_id` across different vendors is legal. Commission math is `floor(gross_minor * split_bps / 10000)`. Migration `0084_vendors.sql`. · *`loyaltyRedemption` primitive — customer-facing reward redemption layer* — `bShop.loyaltyRedemption.create({ query?, loyalty, coupons? })` returns `{ defineReward, getReward, listRewards, updateReward, archiveReward, redeemForCustomer, getRedemption, redemptionsForCustomer, markConsumed, cancelRedemption }`. Reward kinds: `discount_percent / discount_amount / free_product / free_shipping`. `redeemForCustomer` debits points via `loyalty.redeem`, mints a single-use coupon via `coupons` if injected. `max_per_customer` cap enforced across active+consumed; cancelled redemptions free a slot. `cancelRedemption` refunds points only when status is `active` at cancel time — points consumed past the FSM state are operator-only via `loyalty.adjust`. Migration `0085_loyalty_redemptions.sql`. · *`giftRegistry` primitive — wedding / baby / housewarming registries* — `bShop.giftRegistry.create({ query?, catalog })` returns `{ createRegistry, addItem, removeItem, getRegistry, getBySlug, listForOwner, searchPublic, purchaseItem, progressFor, closeRegistry, update }`. Privacy enum: `private / unlisted / public`. Owner-facing purchase reads redact `buyer_customer_id` when `reveal_buyer=false`; storage still carries the id for refund/audit. `active → closed` FSM gates all mutation paths. Over-purchase past `quantity_desired` throws `GIFT_REGISTRY_OVER_PURCHASE`. `progressFor(slug)` returns per-item + overall progress percentages. Migration `0086_gift_registry.sql`. · *`geolocation` primitive — country / locale resolution from request hints* — `bShop.geolocation.create({ query? })` returns `{ resolve, defineCountry, getCountry, listCountries, updateCountry, setGeoBlock, blockedRoster }`. `resolve({ cf_country, cf_region?, accept_language?, timezone?, customer_supplied_country? })` returns `{ country, region?, currency, locale, timezone?, geo_blocked, allowed_payment_kinds, allowed_shipping_kinds }`. Pure validators (ISO 3166-1 alpha-2, ISO 4217, BCP-47, IANA timezone shape). RFC 7231 `Accept-Language` q-sorted parser picks the highest-q tag whose primary subtag matches the row default's primary subtag. Customer-supplied country wins only when a row exists; stale values fall through to `cf_country`. Migration `0092_geolocation.sql`. · *`stockTransfers` primitive — audited multi-step stock movement between locations* — `bShop.stockTransfers.create({ query?, inventoryLocations })` returns `{ openTransfer, markShipped, markInTransit, markReceived, reconcile, markException, getTransfer, listOpen, transfersForLocation, discrepanciesFor }`. Six-state FSM: `open → shipped → in_transit → received → reconciled` (happy) or `→ exception` (terminal sad). Composes `inventoryLocations.adjustStock` as the sole owner of `inventory_stock` writes — origin debited at open, destination credited at reconcile (only by the received qty, so short-ships preserve the audit trail of loss). Discrepancy flag computed at reconcile (`quantity_shipped - quantity_received`), surfaces both short-ships (positive) and over-counts (negative). Three tables: `stock_transfers` + `stock_transfer_lines` + append-only `stock_transfer_events` audit log. Migration `0089_stock_transfers.sql`. · *`smsDispatcher` primitive — outbound SMS with opt-in / opt-out / STOP-keyword auto-handling* — `bShop.smsDispatcher.create({ query? })` returns `{ registerProvider, providerForRegion, recordOptIn, recordOptOut, isOptedIn, handleInboundKeyword, enqueue, markDelivered, markFailed, dispatchTick, messagesForCustomer, messageByProviderRef, hashPhone }`. Phone hashed via `namespaceHash('sms-phone', E.164)`. STOP-keyword auto opt-out: any incoming `STOP` / `UNSUBSCRIBE` / `CANCEL` / `END` / `QUIT` / `STOPALL` registers opt-out + sends a single confirmation. Transient-failure retry on `1m / 5m / 30m / 2h / 12h` back-off; terminal after budget. Non-https endpoints refused. Body refusal for emoji-only / control bytes / zero-width / bidi-override. Migration `0088_sms_dispatcher.sql`. · *`emailCampaigns` primitive — operator-scheduled bulk email campaigns* — `bShop.emailCampaigns.create({ query, mailingAudiences, email, emailSuppressions? })` returns `{ defineCampaign, scheduleCampaign, pauseCampaign, resumeCampaign, cancelCampaign, sendNow, dispatchTick, recordEvent, getCampaign, listCampaigns, metricsForCampaign }`. FSM: draft → scheduled → sending → sent, with paused / cancelled off-ramps. Dispatcher drains the audience in pages via `audiences.resolve`, stamps a `delivered` event per successful `mailer.send`, then transitions sending → sent with `recipients_resolved_count` + `sent_count` + `sent_at` populated. Per-recipient send failures are drop-silent so one bad ESP refusal can't stall the campaign — ESP webhook backfill via `recordEvent` records `bounced` / `unsubscribed`. Migration `0087_email_campaigns.sql`. · *`storefrontDashboards` primitive — operator dashboard composition* — `bShop.storefrontDashboards.create({ query?, salesReports?, analytics?, cartAbandonment?, inventoryAlerts? })` returns `{ defineDashboard, getDashboard, listDashboards, updateDashboard, archiveDashboard, cloneDashboard, renderDashboard }`. Layout enum: `grid_2col / grid_3col / single_column / freeform`. Widget kinds: `revenue_chart / top_products / top_customers / inventory_low_stock / cart_abandonment / order_status_funnel / recent_orders / aov_trend / refund_rate`. `renderDashboard({ slug, from, to, locale? })` dispatches each widget to its injected data source; missing sources yield `data: null`; locale falls back via BCP-47 right-trim through the bundled en / es / fr catalogue. `cloneDashboard({ source_slug, new_slug, new_title?, new_owner? })` — clone starts un-archived even if source is archived. Migration `0091_storefront_dashboards.sql`. · *`refundPolicy` primitive — operator-managed refund eligibility rules* — `bShop.refundPolicy.create({ query? })` returns `{ definePolicy, evaluate, getPolicy, listPolicies, updatePolicy, archivePolicy, auditEvaluation, listAudit }`. Walks active policies in `(priority DESC, created_at ASC, slug ASC)`; the first in-scope policy whose preconditions all pass governs. Refund-kind math: `full` and `store_credit_only` cap at `order_total_minor`; `partial` floors `total*bps/10000`; `no_refund` returns `eligible:false`. Restocking fee subtracts from the cap, clamped at zero with `restocking_fee_exceeds_refund` reason. Calendar-day window computed against UTC-midnight boundaries. `auditEvaluation` records every decision for compliance export. Migration `0090_refund_policy.sql`. **Fixed:** *`smsDispatcher` — monotonic `created_at` so back-to-back enqueues paginate stably* — Same-millisecond `enqueue` calls shared a `created_at` value, and the cursor-based pagination on `messagesForCustomer` couldn't tell consecutive rows apart. The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.

- v0.0.61 (2026-05-22) — **Eleven new primitives: customer import, code minter, storefront forms, cart bulk ops, carrier rates, operator audit log, CMS blocks, gift-card ledger, discount analytics, search facets, dunning.** Eleven primitives in one release. `customerImport` bulk-loads customers from CSV / NDJSON with dedupe by email hash + on_conflict mode. `codeMinter` generates single-use discount codes in bulk over a confusion-resistant alphabet with collision retry. `storefrontForms` is the operator-defined contact / lead-capture / wholesale-application form layer with per-session throttle. `cartBulkOps` is B2B-style cart operations (bulk add, replace, price-list apply, reorder, split). `carrierRates` caches shipping rate quotes per (carrier, service, origin, dest, weight) with TTL. `operatorAuditLog` is the cryptographically-chained tamper-evident audit log for operator-side mutations. `cmsBlocks` is the slot-content CMS for headers, hero copy, footer columns, etc. with markdown render + BCP-47 locale fallback. `giftCardLedger` is the append-only credit/debit/expire ledger on top of `giftcards` with denormalized balance-after column for O(1) balance queries. `discountAnalytics` rolls up coupon impressions + redemptions for operator dashboards. `searchFacets` is the operator-registered facet engine (categorical / numeric_range / boolean) for storefront filter sidebars. `dunning` is the payment-recovery workflow for failed subscription charges. **Added:** *`customerImport` primitive — bulk customer loader with email-hash dedup* — `bShop.customerImport.create({ query?, customers })` returns `{ dryRun, importRows, importFromCsv, importFromNdjson, lastReport, cancelInflight }`. Dedup is by the same `namespaceHash('customer-email', canonical)` digest the `customers` primitive computes — two emails differing only in domain casing collide on a single hash (local-part stays RFC-5321-sensitive). on_conflict modes: `update / skip / error`. CSV-injection refusal happens before the run row opens, so a rejected upload leaves zero rows in both `customers` and `customer_imports`. `cancelInflight` flips status → cancelled and the row-processing loop checks the flag between rows; partial writes persist so the operator re-uploads to fill the gap. Migration `0061_customer_imports.sql`. · *`codeMinter` primitive — bulk single-use discount-code generator* — `bShop.codeMinter.create({ query?, coupons })` returns `{ mintBatch, getBatch, listBatches, codesForBatch, voidBatch, exportBatchCsv, DEFAULT_ALPHABET, STATUSES }`. Default alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (32 glyphs skipping confusion-resistant 0 / 1 / I / O). Custom alphabets use rejection sampling against `Math.floor(256/len)*len` to keep the draw uniform. Per-code collision retry budget of 16; bust raises `CODE_MINTER_COLLISION_BUDGET_EXHAUSTED`. The `code_batch_members.coupon_code UNIQUE` constraint is the authoritative cross-batch gate. `voidBatch` archives every member's coupon via `coupons.archive` and flips status → voided. `exportBatchCsv` yields header `coupon_code,minted_at\r\n` then one CRLF row per code, paginated 500 at a time, ordered for byte-stable re-exports. Migration `0075_code_batches.sql`. · *`storefrontForms` primitive — operator-defined contact / lead-capture forms* — `bShop.storefrontForms.create({ query?, notifications?, webhooks? })` returns `{ defineForm, getForm, listForms, updateForm, archiveForm, submit, submissionsForForm, throttleCheck, hashSession }`. Field kinds: `text / email / phone / textarea / select / checkbox / number`. submit_to kinds: `email / webhook`. Per-session throttle bucketed on the SHA3-512 namespace hash of the session id; `throttle=0` bypasses. Persist-then-dispatch flow with `dispatched_at` / `dispatch_error` stamped on the row. Email-field validation routes through `b.guardEmail`. Migration `0076_storefront_forms.sql`. · *`cartBulkOps` primitive — B2B-style bulk cart operations* — `bShop.cartBulkOps.create({ query?, cart, catalog, customers?, lineGrouper? })` returns `{ addLines, replaceLines, clearLines, priceListApply, reorder, splitCart, quoteForCart, priceLists }`. Atomicity via pre-flight validation: every SKU resolves + price-snapshots before any write; bad row throws `lines[N].sku — …` with zero side effects. `priceListApply` resolves slug from explicit input OR `price_list_assignments[customer_id]`; refuses currency mismatch / archived list / unknown slug. `reorder` skip categories surface as `{ sku, qty, reason }` so the storefront renders "couldn't add N items" affordances. `splitCart` requires an operator-supplied `lineGrouper(line, splitBy)` (no built-in vendor/category guesswork); source cart marked `abandoned` on split. Sub-module `priceLists` exposes `{ create, setMember, assign, listMembers }`. Migration `0078_price_lists.sql`. · *`carrierRates` primitive — at-checkout shipping rate cache* — `bShop.carrierRates.create({ query?, cursorSecret? })` returns `{ registerCarrier, recordRateQuote, ratesForShipment, cleanupExpiredQuotes, recentQuotes, metricsForCarrier }`. Carrier enum: `ups / fedex / usps / dhl / flat_rate`. `recordRateQuote` dedup-upserts on `UNIQUE(carrier_slug, service_code, origin_postal, dest_postal, weight_grams)`. `ratesForShipment` returns sorted-by-cost rates from the cache + a synthetic flat-rate fallback when no live rates apply; expired rows + paused carriers excluded. `rate_endpoint` gated via `b.safeUrl` (https-only). `metricsForCarrier({ slug, from, to })` aggregates by (service_code, currency). Migration `0080_carrier_rates.sql`. · *`operatorAuditLog` primitive — cryptographically-chained tamper-evident operator audit log* — `bShop.operatorAuditLog.create({ query? })` returns `{ record, listByActor, listByResource, searchAction, chainHead, verifyChain }`. Each row carries `row_hash = SHA3-512(prev_hash || canonical-json(row-without-hashes))` so the chain is replay-verifiable. `verifyChain()` recomputes hashes left-to-right and flags any tampered row. `occurred_at` is clamped to be strictly monotonic against the chain head so same-millisecond appends still chain deterministically. Actor enum: `operator / system / app`. UA-class enum: `browser / mobile_app / api_client / cli / bot / unknown`. Opaque base64url `(occurred_at, id)` keyset cursor for the list surfaces. Migration `0074_operator_audit_log.sql`. · *`cmsBlocks` primitive — slot-content CMS with BCP-47 locale fallback* — `bShop.cmsBlocks.create({ query?, translations? })` returns `{ defineBlock, setLocalized, getRendered, listBlocks, archiveBlock, update, versionsForBlock }`. Two-table layout (`cms_blocks` PK + `cms_block_localizations` FK CASCADE) so version history per (key, locale) is queryable and `defineBlock` is idempotent without disturbing localizations. `getRendered` resolution order: archived → short-circuit empty; otherwise walk BCP-47 fallback chain (right-to-left subtag strip), pick highest-version row whose publish window covers `now`; finally fall back to `default_body`. Markdown render composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only allowlist plus /-rooted absolute paths). Migration `0079_cms_blocks.sql`. · *`giftCardLedger` primitive — append-only credit/debit/expire ledger with O(1) balance* — `bShop.giftCardLedger.create({ query?, giftcards? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, bulkBalance, expiringBalance }`. `balance_after_minor` denormalized per row so current balance is O(1) (ORDER BY occurred_at DESC LIMIT 1). Replay-derivability preserved — SUM(credit) - SUM(debit) - SUM(expire) reconstructs balance and is asserted in the test. Per-card `occurred_at` made strictly monotonic via internal `_resolveOccurredAt` (bumps to `prior + 1` on tie or out-of-order operator-supplied timestamp). `expire` caps at current balance (returns `noop: true` when already drained) so scheduled sweeps degrade gracefully against interim debits. `bulkBalance` returns one row per requested id with `balance_minor: 0` for cards with no ledger rows. `expiringBalance` JOINs against `giftcards.expires_at` (canonical expiry source). Migration `0081_gift_card_ledger.sql`. · *`discountAnalytics` primitive — per-coupon + per-tier-set redemption + revenue-impact aggregates* — `bShop.discountAnalytics.create({ query?, coupons?, quantityDiscounts? })` returns `{ recordImpression, recordRedemption, topCoupons, couponPerformance, tierPerformance, revenueImpact, redemptionFunnel }`. Sessions hashed via `namespaceHash('discount-analytics-session', session_id)` at the boundary. Tier-set redemptions tagged with the `coupon_code = 'tier:<tier_set_id>'` convention so the same table backs both surfaces. Process-local monotonic `_nowMs` so back-to-back impressions in the same wall-clock millisecond chain deterministically. Migration `0073_discount_analytics.sql`. · *`searchFacets` primitive — operator-registered facets for storefront filter sidebars* — `bShop.searchFacets.create({ query?, catalog })` returns `{ defineFacet, getFacets, previewQuery, recordFacetUse, listFacets, updateFacet, archiveFacet }`. Kinds: `categorical / numeric_range / boolean`. `getFacets` computes per-facet `{ key, label, kind, options: [{ value, label, count, selected }] }` using the standard "leave focal facet unconstrained" rule so a selected vendor still shows the other vendors' counts. `previewQuery({ filters, sample? })` returns `{ total, sample }` of products matching the candidate filter set. Refuses bad key shape, bad kind, missing/illegal buckets, display_limit on non-categorical, duplicate bucket labels, inverted bucket bounds, key collisions. Session id namespace-hashed before write on `recordFacetUse`. Migration `0082_search_facets.sql`. · *`dunning` primitive — payment recovery workflow for failed subscription charges* — `bShop.dunning.create({ query, subscriptionBilling, email, notifications })` returns `{ defineDunningPolicy, enrollInvoice, tickDunning, statusForInvoice, historyForInvoice, unsetEnrollment, metricsForPolicy }`. Actions: `retry_charge / send_reminder / pause_subscription / cancel_subscription`. Enrollment FSM: `active / recovered / cancelled / abandoned`. `tickDunning({ now })` advances state with composition into email / notifications / billing dependencies — handle methods are surface-optional so a partial wiring still ticks. `metricsForPolicy` aggregates recovery rate + cancellation rate per policy. Migration `0077_dunning.sql`. **Fixed:** *`discountAnalytics` — monotonic `occurred_at` so impressions in the same millisecond sort deterministically* — `Date.now()` on Windows resolves at ~15ms; three impressions inserted in the same tick shared an `occurred_at` and UUID v7's intra-ms randomness scrambled the secondary `id` ordering. The primitive now stamps `occurred_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.

- v0.0.60 (2026-05-22) — **Fourteen new primitives: tax rates, storefront pages, webhook subscriptions, tenants, API keys, barcodes, customer portal, subscription billing, translations, coupon stacking, A/B experiments, print receipts, inventory snapshots, product import.** Fourteen primitives land together. `taxRates` is the operator-managed per-jurisdiction rate table with scheduled effective dates. `storefrontPages` is the markdown-driven CMS for legal / about / shipping copy. `webhookSubscriptions` separates subscription management from delivery. `tenants` runs N branded shops from one deployment with per-tenant domain + currency + theme. `apiKeys` issues hashed-at-rest tokens with scope allowlists + per-key rate limits. `barcodes` maps SKUs to UPC-A / EAN-13 / Code-128 / GTIN-14 values with checksum validation + inline-SVG rendering. `customerPortal` mints single-use scoped portal sessions. `subscriptionBilling` is the invoice + payment-attempt + dunning ledger. `translations` is the per-resource multi-locale lookup with locale-fallback chains. `couponStacking` is the rules engine for which discount codes may be combined. `experiments` is the deterministic-assignment A/B test framework with Wilson confidence intervals. `printReceipts` renders ESC/POS thermal + HTML/PDF + plain-text email receipts. `inventorySnapshots` captures point-in-time stock counts with SHA3-512 tamper-evidence + delta reports. `productImport` is the bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalog data. **Added:** *`taxRates` primitive — per-jurisdiction rate table with scheduled effective dates* — `bShop.taxRates.create({ query? })` returns `{ defineRate, rateFor, listForJurisdiction, updateRate, archiveRate, bulkImport, scheduledChanges }`. Jurisdiction shape is `XX` or `XX-YYY` per ISO 3166. `defineRate` refuses overlapping windows within the same (jurisdiction, category). `rateFor({ jurisdiction, category?, on_date })` resolves category-specific first, then NULL-category fallback. `effective_from` inclusive, `effective_until` exclusive. `scheduledChanges({ from, to })` lists rate transitions in the window for the operator scheduler. Migration `0058_tax_rates.sql` (CHECK enum source: manual / vies / state_dept / avalara). · *`storefrontPages` primitive — markdown CMS for About / Shipping / Privacy / Terms* — `bShop.storefrontPages.create({ query? })` returns `{ defineDraft, publish, unpublish, archive, restore, update, get, getPublished, listPublished, listDrafts, listArchived, renderHtml }`. FSM `draft → published → archived` with `restore` lifting archived → draft. Built-in markdown subset: headings, paragraphs, ul/ol, blockquote, hr, inline code, bold, italic, links. Every text run through `b.template.escapeHtml`; every inline link URL through `b.safeUrl.parse` (https-only + /-rooted internal paths). Unsafe URLs drop the `<a>` entirely so anchor text falls back to inert escaped text. Migration `0059_storefront_pages.sql` (layout enum: default / wide / landing / legal). · *`webhookSubscriptions` primitive — subscription management with 24h secret rotation grace* — `bShop.webhookSubscriptions.create({ query?, now? })` returns `{ subscribe, get, listForOwner, subscriptionsForEvent, pauseSubscription, resumeSubscription, update, unsubscribe, rotateSecret, expireRotationGrace }`. Plaintext signing secret returned ONCE on `subscribe` + `rotate`; at rest only the SHA3-512 namespace hash + a previous-hash for a 24h rotation grace window. `subscriptionsForEvent(event_type)` returns active subscriptions matching the event type for delivery fan-out. `endpoint_url` gated via `b.safeUrl` (https-only). Migration `0060_webhook_subscriptions.sql` (owner_type enum: operator / app / customer). · *`tenants` primitive — multi-store deployment with per-tenant domains + currency + theme* — `bShop.tenants.create({ query? })` returns `{ defineTenant, addDomain, removeDomain, setPrimaryDomain, pauseTenant, resumeTenant, archiveTenant, get, getById, resolveByHost, listTenants, update, stats }`. Slug regex `[a-z0-9]([a-z0-9_-]*[a-z0-9])?` ≤ 64. Domain regex `[a-z0-9][a-z0-9.-]*\.[a-z]{2,}` ≤ 255. `resolveByHost(host)` strips port + lowercases + nearest-match against the registered domain set; paused tenants resolve, archived do not. FSM: `active ↔ paused`, both → `archived` (terminal). Migration `0063_tenants.sql` (`tenants` + `tenant_domains` tables with FK CASCADE). · *`apiKeys` primitive — hashed-at-rest tokens with scope + rate limit + 24h rotation grace* — `bShop.apiKeys.create({ query? })` returns `{ issueKey, verifyToken, revoke, rotate, listForOwner, getKey, update, recordUse, usageForKey, cleanupExpired }`. Plaintext is 32 bytes via `b.crypto.generateBytes` rendered as 43-char URL-safe base64; storage holds only the `namespaceHash('api-key-token', plaintext)` digest. `verifyToken` routes hex compare through `b.crypto.timingSafeEqual`. `rotate` slides the old hash into `token_hash_previous` with a 24h grace + issues a new row. Owner type enum: operator / app / affiliate / tenant. Migration `0064_api_keys.sql` (`api_keys` + append-only `api_key_usage` log). · *`barcodes` primitive — SKU → UPC-A / EAN-13 / Code-128 / GTIN-14 with checksum validation + SVG render* — `bShop.barcodes.create({ query?, catalog })` returns `{ assign, lookup, bySkuList, lookupByValue, unassign, assignAuto, defineRange, listRanges, renderSvg, validateValue }`. Checksum gate per kind (GS1 mod-10 reused for UPC-A / EAN-13 / GTIN-14; Code-128 mod-103 at render). `assignAuto` uses CAS on `next_value` to make concurrent mints race-safe (lost race surfaces as `BARCODE_RANGE_RACE` so the caller can retry). `renderSvg` ships its own EAN/UPC L/G/R tables + ITF-14 widths + Code-128 Set B inline — no external barcode library, no `<script>` / `<foreignObject>` in the output. `validateValue` is a pure-function exported on the module so callers can validate without instantiating the factory. Migration `0068_barcodes.sql`. · *`customerPortal` primitive — single-use scoped customer self-serve sessions* — `bShop.customerPortal.create({ query? })` returns `{ createSession, verifyToken, revokeSession, listForCustomer, expireOlderThan }`. Token plaintext returned once on `createSession`; storage holds only `namespaceHash('customer-portal-token', plaintext)`. Scopes: `full / billing_only / address_only / subscriptions_only / order_history_only`. `verifyToken` flips the row to `consumed` (single-use); constant-time hit/miss equalisation via `b.crypto.timingSafeEqual` on the miss path. Single-use enforced by an `UPDATE … WHERE status='issued'` SQL guard so racing verifies don't both succeed. Default TTL 15 min. Migration `0072_customer_portal_sessions.sql`. · *`subscriptionBilling` primitive — invoice + payment-attempt + dunning ledger* — `bShop.subscriptionBilling.create({ query?, subscriptions, payment? })` returns `{ recordInvoice, recordPaymentAttempt, markPaid, markFailed, enterDunning, exitDunning, invoicesForSubscription, failedInvoices, dunningRoster, arpu }`. Webhook-replay idempotency on `processor_invoice_id` + `(invoice_id, attempt_number)`. Invoice FSM: pending → paid / failed / voided. Dunning states: active / dunning / recovered / cancelled / written_off. `arpu({ from, to })` returns average revenue per user bucketed by currency. `invoice_url` gated via `b.safeUrl`. Migration `0066_subscription_billing.sql` (3 tables with FK CASCADE). · *`translations` primitive — per-resource multi-locale lookup with locale-fallback chain* — `bShop.translations.create({ query? })` returns `{ setTranslation, getTranslation, getForResource, removeTranslation, localesForResource, bulkSet, missingTranslations }`. Locale normalised to BCP-47 canonical form; fallback chain walks right-to-left stripping subtags + appends `en` baseline. `bulkSet` validates every row before writing any (pre-flight-then-write for D1 portability). Values pass through `b.template.escapeHtml` at write so HTML metacharacters land inert while operator-intent newlines survive. `missingTranslations({ resource_kind, locale, sample_size })` reports resources lacking translations in that locale's fallback chain. Migration `0070_translations.sql` (UNIQUE(resource_kind, resource_id, locale, field) backing the upsert). · *`couponStacking` primitive — rules engine for which discount codes may be combined* — `bShop.couponStacking.create({ query?, customerSegments? })` returns `{ definePolicy, getPolicy, listPolicies, updatePolicy, archivePolicy, evaluate, activePoliciesForCart }`. Governing policy = highest-priority active policy whose `order_min_minor` + `customer_segment_in` preconditions match the cart. Conservative default when no policy governs: "first code allowed, rest refused with `no_policy_allows_stacking`". `exclusive_codes` short-circuits + additionally refuses the quantity-discount stack regardless of `with_quantity_discounts`. Refusal reasons: `no_policy_allows_stacking` / `other_codes_not_allowed_to_stack` / `max_codes_per_order_exceeded` / `exclusive_code_present` / `quantity_discount_stack_refused`. Migration `0067_coupon_stacking.sql`. · *`experiments` primitive — A/B testing with deterministic assignment + Wilson confidence intervals* — `bShop.experiments.create({ query? })` returns `{ defineExperiment, getVariant, recordConversion, metricsForExperiment, listExperiments, pauseExperiment, resumeExperiment, archiveExperiment, update }`. Variant assignment is deterministic per session: `namespaceHash('experiments-assign', slug + ':' + sessionHash)` → first 64 bits mod cumulative weight (computed in two 32-bit halves to stay inside JS's 53-bit safe-integer territory). Sessions assigned 100x return the same variant 100x. `variants_json` is read-only after `defineExperiment` — changing variants would corrupt prior assignments. `recordConversion` is drop-silent on unknown experiment / variant / archived / bad-shape (hot-path). `metricsForExperiment` returns per-variant counts + Wilson 95% CI when `assigned_sessions` is supplied. Migration `0071_experiments.sql` (FSM: draft / running / paused / archived terminal). · *`printReceipts` primitive — ESC/POS thermal + HTML/PDF + plain-text receipt rendering* — `bShop.printReceipts.create({ query?, order })` returns `{ thermal, htmlPdf, plainText, previewBuffer, recordPrint, printsForOrder }`. `thermal({ paper_width_mm? })` defaults to 80mm cols; 58mm option supported. `htmlPdf` returns an HTML string suitable for headless-Chrome PDF conversion. `plainText` scrubs C0 control bytes + DEL on output. Every operator-input ship-to field passes through `b.template.escapeHtml` for HTML output. Each print writes a `receipt_prints` audit row with byte size + SHA3-512 hash so an operator can prove what came off the printer. `previewBuffer` returns the rendered string WITHOUT writing the audit row (operator preview path). Migration `0062_print_receipts.sql`. · *`inventorySnapshots` primitive — point-in-time stock captures with SHA3-512 tamper-evidence + delta reports* — `bShop.inventorySnapshots.create({ query?, catalog, inventoryLocations? })` returns `{ takeSnapshot, getSnapshot, listSnapshots, deltaBetween, summary, purgeOlderThan }`. Single-bucket capture reads from `inventory` (location_code NULL); multi-location capture reads from `inventory_stock` when `inventoryLocations` is wired. `deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })` returns per-(sku, location) deltas tagged `added / removed / changed / unchanged`. `summary(snapshot_id)` returns counts + total units + flagged stockout outliers + tamper-evidence check (recomputes the SHA3-512 hash + flags mismatch). `purgeOlderThan(days)` walks expired snapshots — FK CASCADE removes the detail rows. Migration `0065_inventory_snapshots.sql`. · *`productImport` primitive — bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalogs* — `bShop.productImport.create({ query?, catalog })` returns `{ dryRun, importRows, importFromCsv, importFromJson, lastReport, cancelInflight, listImports, errorsForImport }`. Formats: `flat_csv / shopify_json / blamejs_native`. on_conflict modes: `update / skip / error`. Within-import SKU dedupe surfaces as `duplicate_sku_in_import` row-error. Shopify JSON shape converts decimal prices to minor units. RFC-4180 CSV with quoted-comma + quote-doubling. Per-row errors persist to `product_import_errors` for operator triage. Migration `0069_product_imports.sql`.

- v0.0.59 (2026-05-22) — **Eleven new primitives: collections, customer segments, recently-viewed, stock alerts, shipping + return labels, promo banners, search synonyms, affiliates, mailing audiences, order timeline.** Eleven primitives ship in one release. `collections` registers manual + smart product groupings. `customerSegments` evaluates RFM-style rules over order history. `recentlyViewed` tracks per-customer / per-session product browse history. `stockAlerts` issues back-in-stock subscriptions with confirmation tokens. `shippingLabels` + `returnLabels` are the carrier-agnostic label-management primitives. `promoBanners` is the storefront CMS for placement-targeted marketing. `searchSynonyms` rewrites and corrects customer search queries. `affiliates` runs the partner program with single-use codes + per-affiliate commission ledgers. `mailingAudiences` is the newsletter segmentation primitive. `orderTimeline` aggregates events from every order-touching primitive into a single chronologically-sorted feed with operator-friendly summary memoization. **Added:** *`collections` primitive — manual + smart product collections* — `bShop.collections.create({ query?, catalog, cursorSecret? })` returns `{ defineManual, defineSmart, get, list, update, archive, addProduct, removeProduct, reorderProducts, productsIn, collectionsForProduct, evaluateRules }`. Manual collections store an explicit ordered membership; smart collections evaluate rules against the catalog at read time. Rules support `eq / neq / contains / gt / gte / lt / lte / in / not_in / between` with field-class guards. `collectionsForProduct(product_id)` unions manual membership with active-smart-rule matches. `update` uses `b.safeSql.assertOneOf` against an `ALLOWED_COLUMNS` allowlist. Migration `0043_collections.sql`. · *`customerSegments` primitive — RFM-style segmentation with cached membership* — `bShop.customerSegments.create({ query?, cursorSecret? })` returns `{ defineSegment, evaluate, recompute, segmentsForCustomer, listSegments, update, archive, unarchive, stats }`. Rules ANDed across `recency_days_max/min`, `frequency_orders_min`, `lifetime_orders_min/max`, `monetary_minor_min/max`, `aov_minor_min`, `refund_rate_bps_min/max`, `last_order_status_in`, `country_in`, `currency_in`. Cancelled orders excluded from aggregates. `recompute()` refreshes the membership cache; subsequent `evaluate(slug)` reads off the cache for paginated iteration. Migration `0049_customer_segments.sql`. · *`recentlyViewed` primitive — per-customer / per-session browse history* — `bShop.recentlyViewed.create({ query?, catalog })` returns `{ recordView, forCustomer, forSession, merge, recommend, cleanupOlderThan, purgeCustomer }`. Session ids are namespace-hashed (`recently-viewed-session`) before write — the log never holds a recoverable identifier. Re-viewing a product within 5 minutes updates the existing row's `last_viewed_at` + increments `view_count` rather than writing a new one. `forCustomer({ exclude_purchased: true })` filters out products the customer has already bought. `merge({ session_id, customer_id })` rolls a guest session into the customer record at login. Sliding-window cap default 20. Migration `0050_recently_viewed.sql`. · *`stockAlerts` primitive — back-in-stock notifications with confirmation tokens* — `bShop.stockAlerts.create({ query?, catalog, notifications? })` returns `{ subscribe, unsubscribe, confirm, isSubscribed, listForSku, listForCustomer, scanAndNotify, cleanupExpired, stats, hashEmail }`. `subscribe` mints a 32-char base64url confirmation token (hash stored, plaintext returned once). `scanAndNotify({ now })` walks pending alerts where `stock_on_hand - stock_held > 0` and (when `notifications` is wired) enqueues an in-app message under event-type `stock.back-in-stock`. Email + token always hashed via `namespaceHash` before write. Migration `0048_stock_alerts.sql`. · *`shippingLabels` primitive — carrier-agnostic shipping label management* — `bShop.shippingLabels.create({ query?, cursorSecret? })` returns `{ requestLabel, markPurchased, voidLabel, markUsed, getLabel, labelsForShipment, labelsForOrder, pendingLabels, voidedInWindow, costsByPeriod, customsForLabel }`. `purchased_via` enum: `easypost / shippo / shipstation / stamps_com / manual / custom`. `label_url` gated through `b.safeUrl.parse` (https-only). `voidLabel` refuses past the carrier's 30-day window with a typed error code. `costsByPeriod({ from, to, carrier? })` groups by purchased_via + currency for cost-allocation dashboards. The actual label-generation HTTP call is the operator's worker. Migration `0051_shipping_labels.sql`. · *`returnLabels` primitive — operator-funded return-shipping flow* — `bShop.returnLabels.create({ query?, returns? })` returns `{ issueLabel, markShipped, markInTransit, markDelivered, markException, getLabel, labelForReturn, pendingPickup, inTransit, eventsForLabel }`. FSM: `issued → shipped → in_transit → delivered`, with an `exception` terminal branch. `markInTransit` accepts re-entry so a multi-hop journey produces a row-per-scan timeline. `markDelivered` calls the injected `returns.markReceived` callback so the RMA flips to `received` status automatically. `issueLabel` refuses unless the underlying return is in `approved` status. Migration `0052_return_labels.sql`. · *`promoBanners` primitive — operator-controlled marketing banners across storefront placements* — `bShop.promoBanners.create({ query?, customerSegments? })` returns `{ defineBanner, activeForPlacement, listAll, getBanner, updateBanner, archive, unarchive, renderHtml, impressionCount, clickCount, recordImpression, recordClick }`. Placements: `top_strip / homepage_hero / pdp_side / cart_side / search_empty / footer`. Audiences: `all / logged_in / guest / segment`. Themes: `info / promo / urgency / success`. `cta_url` gated through `b.safeUrl.parse` (https-only + /-rooted internal paths). `renderHtml` escapes every operator-input field via `b.template.escapeHtml` so script / img-onerror payloads render inert. `recordImpression` / `recordClick` drop-silent on bad slug (hot-path). Migration `0053_promo_banners.sql`. · *`searchSynonyms` primitive — query rewriting + typo correction + stopword removal* — `bShop.searchSynonyms.create({ query? })` returns `{ addGroup, addTypo, addStopword, removeStopword, getGroup, listGroups, updateGroup, deleteGroup, listTypos, listStopwords, rewrite, learnFromQueries }`. Group kinds: `bidirectional` (any term matches any other) + `directional` (terms[0..N-1] all map to terms[N], canonical is terms[N]). `rewrite(query, { include_corrections?, max_expansions? })` returns `{ canonical, expansions, corrections }`. Tokenise → strip control bytes (don't refuse — denial-of-search would block legitimate shoppers) → stopword drop → typo correction → conservative English stemmer (`-ies / -ing / -ed / -es / -s`, min length 4). `learnFromQueries({ from, to, min_count })` scans `search_query_log` for term co-occurrences not yet covered by an existing group. Migration `0055_search_synonyms.sql`. · *`affiliates` primitive — partner program with single-use codes + per-affiliate commission ledger* — `bShop.affiliates.create({ query?, cursorSecret? })` returns `{ registerAffiliate, getAffiliate, affiliateByCode, listAffiliates, updateAffiliate, pauseAffiliate, reinstateAffiliate, recordVisit, attributionForSession, recordCommissionEvent, commissionsForAffiliate, markCommissionPaid, markCommissionVoided, payoutsDue, topAffiliates }`. Codes are 8 chars over a confusion-resistant 32-glyph alphabet. Commission kinds: `percent_bps / amount_per_order_minor / amount_per_signup_minor`. `commission_minor` is computed at write time and stored — historical payouts don't shift when the operator later edits the affiliate's rate. `recordCommissionEvent` is idempotent on `(order_id, affiliate_id)`. Email + visitor session id always hashed via `namespaceHash` before write. Migration `0057_affiliates.sql`. · *`mailingAudiences` primitive — newsletter audience segmentation with suppression filtering* — `bShop.mailingAudiences.create({ query?, newsletter?, emailSuppressions?, cursorSecret? })` returns `{ defineAudience, resolve, count, getAudience, listAudiences, update, archive, recompute, auditDelivery, listDeliveries }`. Audience rules over `newsletter_signups`: `subscribed_after/before`, `source_in`, `tag_any/all`, `country_in`, `double_opt_in`, `language_in`, `customer_status_in`. `resolve({ slug, skip_suppressed: true })` filters out suppressions via the injected `emailSuppressions` peer (default true). Defaults: emails return hashed only; plaintext requires `include_plaintext: true` for verifier-role-gated routes. `recompute()` refreshes the per-slug membership cache. `auditDelivery` writes append-only rows for compliance export. Unsubscribed signups never match any rule. Migration `0056_mailing_audiences.sql` (+ additive columns on `newsletter_signups` for `tags_csv` / `country` / `language` / `customer_status` / `double_opt_in_at`). · *`orderTimeline` primitive — unified per-order event feed across every order-touching primitive* — `bShop.orderTimeline.create({ query?, order?, orderTracking?, orderNotes?, returns?, payment?, fraudScreen?, shippingLabels?, notifications? })` returns `{ forOrder, summarize, customerVisibleFor, compareOrders, recentActivity }`. Aggregates events from `order_transitions`, `shipment_events`, `order_notes`, `return_authorizations`, `fraud_screenings`, `shipping_labels`, and `notifications` into a chronologically-sorted feed newest-first. `customerVisibleFor({ order_id, locale })` filters to events the customer should see (en / es / de built-in, unknown locales fall back to English). `summarize(order_id)` returns `{ status, first_event_at, last_event_at, event_count, milestones: { paid_at?, fulfilled_at?, shipped_at?, delivered_at?, refunded_at? }, cache_hit }`. Cache invalidation tracks both `last_event_at` AND a per-source row count so a same-millisecond event still flushes the cache. Every injected peer is optional. Migration `0054_order_timeline_cache.sql`. **Fixed:** *`returnLabels.issueLabel` — accepts an optional `issued_at` epoch-ms for deterministic test ordering* — Synthetic-timestamp tests that mix `markShipped({ shipped_at: ... })` with the issuer's wall-clock `Date.now()` could see the issued event sort AFTER the synthetic ones. `issueLabel` now accepts `issued_at` so the full timeline can be authored from a single clock source. Production callers omit the field; `Date.now()` is the default. · *`orderTimeline.summarize` — cache freshness check covers every source the collector walks* — `_sourceStats` now joins `shipping_labels` (via `shipments.order_id`) and `notifications` (via `payload_json` `LIKE '%"order_id":"<uuid>"%'`) so the per-source row count matches `_collectAll`. Previously, an order with any shipping-label or notification rows had a mismatched count between cache-write and cache-check, and the cache never registered as fresh. The second `summarize(order_id)` call now correctly hits the cache.

- v0.0.58 (2026-05-22) — **Five new primitives: sales reports, quantity discounts, subscription controls, gift options, support tickets.** Five primitives ship in one release. `salesReports` aggregates orders for operator dashboards with cursor-paginated detail views + opt-in memoization. `quantityDiscounts` adds automatic tier pricing (buy N for X% off) scoped per SKU / product / collection / vendor / category / global, distinct from coupon codes. `subscriptionControls` layers pause / resume / skip-next / change-quantity / change-frequency / cancel / reactivate on top of the existing `subscriptions` surface with a full audit ledger. `giftOptions` adds gift wrapping (operator-configured wrap SKUs with fees) + gift message + recipient-name + gift-receipt toggle, with HTML-escape at every rendered field. `supportTickets` is a broader customer-service surface than `orderNotes` — pre-sale questions, account problems, complaints — with threaded messages, priority FSM, SLA timer, and per-operator assignment. **Added:** *`salesReports` primitive — order aggregations for operator dashboards* — `bShop.salesReports.create({ query?, cursorSecret? })` returns `{ revenueByDay, revenueByWeek, revenueByMonth, topProducts, topCustomers, revenueByCountry, revenueByPaymentMethod, customerCohort, aov, refundRate, funnel, purgeExpired }`. Each surface accepts `{ from, to, currency? }`; top-N surfaces add `{ limit, cursor? }` for HMAC-paginated detail. Opt-in memoization (`cache: true`) writes to a `sales_report_cache` row keyed on `(report_key, params_hash)` with operator-tuned TTL; default is fresh recompute so first-load dashboards never serve stale data. `customerCohort(cohort_month)` returns per-month retention for the cohort that first ordered in `cohort_month`. `funnel({ from, to })` counts checkout-started / payment-intent-created / paid / fulfilled / refunded. Migration `0042_sales_report_cache.sql` (id, report_key, params_hash, result_json, computed_at, expires_at + indexes on (report_key, expires_at), (params_hash)). · *`quantityDiscounts` primitive — automatic tier pricing per SKU / product / collection / vendor / category / global* — `bShop.quantityDiscounts.create({ query?, catalog })` returns `{ defineTier, getTiersForLine, applyToLine, applyToCart, update, archive, unarchive, list, tierBreakdown }`. Discount kinds: `percent_off (bps)`, `amount_off_each (minor)`, `amount_off_total (minor)`, `fixed_each_price (minor)`. `applyToLine` returns `{ original_unit_minor, discounted_unit_minor, line_subtotal_minor, line_discount_minor, applied_tier_id }` after walking applicable rules ordered by scope-specificity (sku > product > collection > vendor > category > global) and picking the best one. `exclusive: true` on a tier set short-circuits stacking. `defineTier` refuses overlapping `min_quantity` values within a single tier set so the schedule is unambiguous. Per-unit math composes `b.money.fromMinorUnits(...).multiply([num, den])` for half-even rounding consistent with the framework Money class. Migration `0044_quantity_discounts.sql` (`qd_tier_sets` + `qd_tiers` tables). · *`subscriptionControls` primitive — pause / resume / skip / change-quantity / change-frequency / cancel / reactivate* — `bShop.subscriptionControls.create({ query?, subscriptions })` returns `{ pause, resume, skipNext, changeQuantity, changeFrequency, cancel, reactivate, historyForSubscription, actorReport, scanAutoResume }`. `pause({ until?, reason })` flips active → paused; an `until` value schedules auto-resume via `scanAutoResume(now)`. `skipNext({ count, reason })` bumps `next_billing_at` by N periods without changing status; `changeFrequency({ new_frequency, reason })` recomputes `next_billing_at` on the new schedule (weekly / biweekly / monthly / quarterly / semiannual / annual). `cancel({ immediate? })` defaults to end-of-period (customer keeps access through the paid period); `immediate: true` ends now. `reactivate` allows cancelled → active within a 90-day grace window; refuses past. Every state change writes a `subscription_control_events` row with `actor_type`, `actor_id`, `before_json`, `after_json`, and a 280-char operator-prose `reason` so customer service can replay the full state arc. Migration `0045_subscription_controls.sql` (new event table + ALTER TABLE additions to `subscriptions` for `paused_at` / `paused_until` / `cancelled_at` / `quantity` / `frequency` / `next_billing_at`). · *`giftOptions` primitive — gift wrapping, gift message, gift receipt* — `bShop.giftOptions.create({ query?, catalog })` returns `{ defineWrap, listWraps, getWrap, updateWrap, archiveWrap, setForOrder, getForOrder, clearForOrder, feeForOrder, renderPackingSlipLine, analytics }`. `wrap_sku` references a real catalog SKU so wrap inventory + cost flow through normal channels. `setForOrder({ order_id, wrap_sku?, gift_message?, recipient_name?, hide_prices? })` validates `gift_message` (≤ 500 chars, control-byte-free, zero-width-char-free) + `recipient_name` (≤ 120 chars) + refuses unknown / archived wrap_sku. `renderPackingSlipLine({ order_id, locale })` returns `{ message_lines, recipient_name, hide_prices }` with every operator-input field HTML-escaped via `b.template.escapeHtml` (script-in-message / img-onerror payloads are rendered inert). `analytics({ from, to })` powers the operator dashboard (gift-option order count, top wrap_skus, gift-message rate). Migration `0046_gift_options.sql` (`gift_wraps` + per-order `gift_options` rows). · *`supportTickets` primitive — threaded customer-service surface with SLA timers* — `bShop.supportTickets.create({ query?, cursorSecret? })` returns `{ open, reply, transition, assign, unassign, addTag, removeTag, get, listForCustomer, listOpenAssignedTo, listUnassigned, thread, slaCheck, metrics }`. Distinct from `orderNotes` — a ticket isn't necessarily tied to an order. Categories: `pre_sale / order_issue / shipping / billing / refund / account / complaint / feature_request / other`. Priority: `low / normal / high / urgent`. Status FSM: `new → in_progress → waiting_customer → in_progress → resolved → closed`, with `reopened` re-entering `in_progress` and `closed` reachable from any state. Operator reply auto-flips `new → in_progress`. Email always hashed via `namespaceHash('support-ticket-email', ...)` before write. `slaCheck()` returns tickets breaching SLA (urgent 1h / high 4h / normal 24h / low 72h since last operator action). `metrics({ from, to })` covers per-category counts, average first-response time, resolution rate, escalation rate. Migration `0047_support_tickets.sql` (`support_tickets` + `support_messages` + `support_status_history` tables).

- v0.0.57 (2026-05-22) — **Ten new primitives: bundles, variants, multi-warehouse inventory, backorder, payment methods, order notes, fraud screening, order export, print-on-demand, save-for-later.** Ten primitives ship in one release. `bundles` registers kit-products that expand at cart resolution time. `variants` is the option-axis layer that lets a single product carry the cartesian product of color × size × style (etc.) with per-variant overrides. `inventoryLocations` splits stock across N warehouses + retail stores + drop-ship suppliers with a routing strategy. `backorder` lets out-of-stock SKUs still be ordered with an expected-ship date carried to the PDP. `paymentMethods` stores per-customer processor tokens (Stripe payment-method ids, PayPal billing-agreement ids, etc.) with strict refuses on PAN / CVV-shaped fields. `orderNotes` adds a threaded customer-service surface attached to each order with `internal` and `customer_visible` visibility. `fraudScreen` returns a `{score, decision, signals}` triage at checkout from a 13-signal heuristic. `orderExport` streams CSV / NDJSON / scheduled-export rows with CSV-injection refusal at every cell. `printOnDemand` is the supplier-agnostic POD passthrough (Printful / Printify / Gelato / Lulu / Gooten / Cloudprinter + `custom`). `saveForLater` parks a cart line on the customer's account without losing the snapshot price. **Added:** *`bundles` primitive — kit / multi-pack products that expand at cart resolution* — `bShop.bundles.create({ query?, catalog })` returns `{ defineBundle, getBundle, listBundles, expand, priceBundle, updateBundle, deleteBundle }`. Bundle SKUs reference real catalog SKUs (or nested bundle SKUs up to 2 levels). `expand({ bundle_sku, quantity })` returns the flattened component list with multipliers applied. `priceBundle` sums child priceMinor and applies an optional `bundle_discount_bps` override. Cycle detection refuses bundles that eventually point back to themselves. `deleteBundle` refuses while any active cart line references the bundle. Migration `0032_bundles.sql` (`bundles` + `bundle_components` tables with UNIQUE(bundle_sku, sku) + FK CASCADE). · *`variants` primitive — product option matrix with per-variant overrides* — `bShop.variants.create({ query?, catalog })` returns `{ defineAxis, generateMatrix, materializeMatrix, getVariant, variantsForProduct, findVariant, updateVariant, archiveVariant, unarchiveVariant, archiveAxisOption }`. Operators register axes (`color`, `size`, etc.) with positions and option labels. `generateMatrix` previews the cartesian product without writing — the operator validates before `materializeMatrix` commits. SKU shape: `<prefix>-<axis1-opt>-<axis2-opt>-...` lowercase ASCII slug. `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })` resolves a specific cell. `image_url` validates via `b.safeUrl` so `javascript:` / `data:` schemes are refused at write. Migration `0033_product_variants.sql` (`product_variant_axes` + `product_variant_axis_options` + `product_variants` tables). · *`inventoryLocations` primitive — multi-warehouse stock + order routing* — `bShop.inventoryLocations.create({ query?, catalog })` returns `{ defineLocation, listLocations, getLocation, updateLocation, deactivateLocation, setStock, adjustStock, transferStock, stockForSku, totalForSku, availableLocations, routeOrder }`. Each location has a type (`warehouse` / `retail` / `dropship`) + priority + active flag. `transferStock` is an atomic two-row write so no stock is created or lost. `routeOrder` strategies: `priority-fill` (default — walks active locations in priority order), `single-location-cheapest`, `nearest-by-postal-prefix`. Returns `{ allocation: [{ location_code, lines }], unfulfillable }`. `inventory_adjustments` ledger is append-only — every `adjustStock` writes a reason-tagged row for audit replay. Migration `0034_inventory_locations.sql`. · *`backorder` primitive — orderable when out-of-stock with expected-ship dates* — `bShop.backorder.create({ query?, catalog })` returns `{ markBackorderable, markNotBackorderable, getStatus, listBackorderable, recordBackorder, fulfillBackorder, cancelBackorder, availabilityFor, customerBackorders, pendingForSku, arrivalsThisWeek }`. `availabilityFor(sku)` returns `{ status: 'in_stock' | 'backorderable' | 'out_of_stock', expected_ship_date?, message? }` for the PDP. `recordBackorder` enforces the per-sku `max_backorder_quantity` cap (NULL = unlimited). `arrivalsThisWeek` returns backorders with `expected_ship_date <= now + 7d` for operator-facing dashboards. Migration `0035_backorder.sql` (`backorder_skus` config table + `backorder_lines` per-order rows with status FSM (pending / fulfilled / cancelled)). · *`paymentMethods` primitive — saved processor tokens with PAN / CVV refusal* — `bShop.paymentMethods.create({ query? })` returns `{ add, get, listForCustomer, setDefault, archive, markExpired, defaultForCustomer, byProcessorToken, audit }`. Add refuses any field that looks like a raw PAN (regex: 13-19 digits in a row, including hyphen / space-collapsed forms), refuses CVV-shaped fields, validates processor enum (stripe / paypal / square / braintree / authorize_net), validates last4 (exactly 4 digits), validates expiry not in the past. `setDefault` enforces write-side default-uniqueness — promoting clears sibling default in the same transaction. `markExpired` is operator-scheduler-callable and writes an audit row per archived method. `audit(payment_method_id)` returns the row's full state-change history. Migration `0036_payment_methods.sql` (`payment_methods` + `payment_method_audit` tables with partial UNIQUE index on `(customer_id) WHERE is_default = 1 AND archived_at IS NULL`). · *`orderNotes` primitive — threaded customer-service notes per order* — `bShop.orderNotes.create({ query?, cursorSecret? })` returns `{ add, get, listForOrder, thread, markRead, pin, unpin, customerVisibleForOrder, internalForOrder, resolve, reopen }`. Visibility enum: `internal` (operator-only) / `customer_visible` (rendered on the customer's order page + emailed). Body cap 8000 chars, refuses control bytes + zero-width chars + empty-after-trim. `thread` returns a tree-shaped reply graph. `pin` floats a note to the top of the listing; resolve / reopen runs an internal-thread workflow with a 280-char resolution summary. Migration `0037_order_notes.sql` (`order_notes` + `order_note_reads` tables with indexes on (order_id, visibility, created_at desc) and (order_id, pinned desc, created_at desc)). · *`fraudScreen` primitive — heuristic risk score + decision at checkout* — `bShop.fraudScreen.create({ query?, customers?, addresses?, paymentMethods?, emailSuppressions? })` returns `{ screen, recordOutcome, recordChargeback, flagEmail, unflagEmail, customerRiskHistory, recentScreenings, hashEmail }`. `screen({ order_draft })` returns `{ score: 0..100, decision: 'approve' | 'review' | 'step_up' | 'refuse', signals }`. Decision thresholds: 0-39 approve, 40-69 review, 70-89 step_up, 90+ refuse. 13 signals fire independently and stack with operator-tunable weights: velocity (>N orders/24h from same email hash), high-value-new-customer, address-mismatch, free-email-domain, disposable-email-domain, session-too-fast (< 15s), session-too-old (> 24h), ua-curl-class, large-line-count, mismatched-bin-country (operator-supplied BIN → country map), prior-chargeback (looks up chargebacks ledger by email hash), suppressed-email, manually-flagged. Emails always hashed via `namespaceHash('fraud-email', ...)` before write. Migration `0038_fraud_screenings.sql` (`fraud_screenings` + `fraud_chargebacks` + `fraud_email_flags` tables). · *`orderExport` primitive — CSV / NDJSON streaming export with CSV-injection refusal* — `bShop.orderExport.create({ query?, order, cursorSecret? })` returns `{ csvForRange, ndjsonForRange, summaryForRange, scheduleExport, cancelExport, getExport, listExports, markExportRunning, markExportComplete, markExportFailed }`. Built-in 24-column schema covering order id / status / customer hash / addresses / line counts / monetary totals / payment method / fulfillment status / refund / return / chargeback flags. RFC-4180 quoting with CRLF; cell content starting with `=`, `+`, `-`, `@` gets prefixed with `'` per OWASP guidance (signed numerics like `+15.00` are exempt). Email is hashed via `namespaceHash('order-export-email', ...)` — raw email never reaches the export. `scheduleExport` writes a queued row + the operator's worker walks the `pod_fulfillments`-style FSM (queued → running → complete | failed | cancelled). Migration `0039_scheduled_exports.sql`. · *`printOnDemand` primitive — supplier-agnostic POD passthrough* — `bShop.printOnDemand.create({ query?, catalog })` returns `{ bindSku, unbindSku, getBinding, listBindings, updateBinding, costForOrder, forwardOrder, markFulfillmentSubmitted, markFulfillmentShipped, markFulfillmentFailed, markFulfillmentCancelled, getFulfillment, fulfillmentsForOrder, pendingFulfillments }`. Supplier enum: `printful` / `printify` / `gelato` / `lulu` / `gooten` / `cloudprinter` / `custom`. The primitive stores the SKU → supplier-product binding (artwork URL + variant id + position config + colorway + operator's wholesale `cost_minor`) and the per-order pending-fulfillment row. The actual HTTP push to each supplier is the operator's worker (each has its own auth + endpoint). `costForOrder(order_lines)` returns `{ total_cost_minor, by_supplier, unbound_skus }` for margin reporting. Migration `0040_print_on_demand.sql` (`pod_bindings` + `pod_fulfillments` tables with FSM-driven status column). · *`saveForLater` primitive — park a cart line on the customer account* — `bShop.saveForLater.create({ query?, catalog, currency?, cursorSecret? })` returns `{ moveFromCart, moveToCart, add, remove, clear, listForCustomer, countForCustomer, staleCheck, repriceAll, expireOlderThan }`. Distinct from `wishlist` — `wishlist` is "I'm interested", `saveForLater` is "I was about to buy this but not now". Lines carry their original cart context (price-at-save, variant, quantity, notes) so the customer can re-add with the snapshot price or the current price (`moveToCart` `use_price` parameter). `staleCheck` flags `is_stale` (price changed), `is_unavailable` (SKU gone), `is_low_stock` (catalog quantity < quantity-saved) per row. Refuses re-add to cart when the SKU is now out-of-stock and not backorderable. `expireOlderThan(days)` is the operator-scheduler sweep. Migration `0041_save_for_later.sql` with UNIQUE(customer_id, sku, COALESCE(variant_id, '')). **Fixed:** *`orderNotes` — monotonic `created_at` so back-to-back inserts always sort newest-first* — `Date.now()` on Windows resolves at ~15ms — three notes inserted in the same millisecond shared a `created_at` value, and the secondary `id DESC` tiebreaker fell back to UUID v7's random suffix (unstable since the time prefix collapsed). The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value, so the listing's `pinned DESC, created_at DESC, id DESC` ordering reliably places the most recent note first regardless of insert burstiness.

- v0.0.56 (2026-05-22) — **Republish of the eleven-primitive batch to npm.** 0.0.55 source landed on `main` but the npm publish workflow ran against the prior commit and didn't reach the registry. 0.0.56 republishes the same eleven primitives (`orderTracking`, `returns`, `loyalty`, `referrals`, `notifications`, `addresses`, `taxExempt`, `emailSuppressions`, `currencyDisplay`, `searchSuggestions`, `cartAbandonment`) plus migrations `0021`-`0031` so `npm install @blamejs/blamejs-shop@0.0.56` pulls the full surface. No code changes from 0.0.55 — only the version bump + this changelog entry. **Fixed:** *Republish to npm — 0.0.55 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.55` would resolve to a missing version. 0.0.56 ships the identical surface so operators upgrading by version number get the full eleven-primitive batch + the eleven new migrations. Tag `v0.0.55` remains on the repo; operators referring to either version see equivalent code.

- v0.0.55 (2026-05-22) — **Eleven new primitives: order tracking, returns, loyalty, referrals, notifications, addresses, tax exemptions, email suppressions, multi-currency display, search suggestions, cart abandonment.** Eleven primitives land in one release, each composed on the vendored blamejs surface (`b.crypto.namespaceHash`, `b.guardEmail`, `b.guardUuid`, `b.uuid.v7`, `b.pagination`, `b.safeSql`, `b.money`). Order tracking + shipments now drive carrier deep-links (UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post). Returns ship a full RMA FSM (pending → approved → received → refunded). Loyalty awards points + tier bands. Referrals issue two-sided rewards. Notifications queue with retry + preferences. Saved addresses give customers a per-account book with default-shipping/billing uniqueness. Tax exemption certificates carry an approve/reject/revoke pipeline + a region-aware `isExempt` check. Email suppressions gate outbound mail per scope (transactional / marketing / all). Multi-currency display caches FX rates as integer basis-points with a staleness flag. Search suggestions blend operator-featured rows with popular queries. Cart abandonment scans idle carts and emits per-detection rows for downstream reminder fan-out. **Added:** *`orderTracking` primitive — shipments + per-event log + carrier deep-links* — `bShop.orderTracking.create({ query?, cursorSecret? })` returns `{ createShipment, addEvent, getShipment, listForOrder, byTrackingNumber, carrierTrackingUrl, statusFromCarrier, latestStatus }`. Shipment status flows `label_created → in_transit → out_for_delivery → delivered`, with `exception` + `returned` terminal branches. `carrierTrackingUrl(carrier, tracking_number)` returns the carrier deep-link from a built-in URL template table for UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post; unknown carriers return `null` rather than guessing. `addEvent` appends a `shipment_events` row with the carrier-reported status string + raw timestamp + free-form description. Migration `0021_shipments.sql` (shipments + shipment_events tables, indexes on (order_id, created_at), (tracking_number), (status, created_at)). · *`returns` primitive — RMA workflow with per-line reason codes* — `bShop.returns.create({ query?, cursorSecret? })` returns `{ open, approve, reject, markReceived, refund, get, listForCustomer, listForOrder, byStatus }`. The RMA FSM is `pending → approved → received → refunded` with a `rejected` terminal branch from `pending`. Each line carries a reason code from the operator-config'd enum (default: `defective`, `wrong_item`, `wrong_size`, `changed_mind`, `damaged_in_transit`, `not_as_described`, `arrived_late`, `other`) + an optional 280-char note. `refund` writes the refund amount in minor units + the operator's payment-processor refund id. Migration `0023_returns.sql` (returns + return_lines tables, FK CASCADE, indexes on (customer_id, created_at desc), (order_id), (status, created_at desc)). · *`loyalty` primitive — tier bands + point ledger with earn / redeem / expire* — `bShop.loyalty.create({ query?, cursorSecret? })` returns `{ award, redeem, expire, balance, tierForCustomer, history, customersInTier, recomputeTier }`. Default tiers are `bronze (0)`, `silver (250)`, `gold (1000)`, `platinum (5000)` with operator-overridable thresholds. Point ledger is append-only (`earn` / `redeem` / `expire` / `adjust` row types) so totals are always derivable + audit-replayable. `expire` walks rows whose `expires_at <= now` and emits an offsetting `expire` row equal to the unconsumed remainder. Migration `0022_loyalty.sql` (loyalty_ledger + customer_loyalty_summary cached-aggregate tables, indexes on (customer_id, occurred_at desc), (expires_at) where expires_at IS NOT NULL). · *`referrals` primitive — two-sided reward with single-use codes* — `bShop.referrals.create({ query?, cursorSecret? })` returns `{ issueCode, redeem, getCode, listForReferrer, statusForCode, expireScan, statsForReferrer }`. `issueCode` mints a per-referrer code (8-char crockford-base32) and stores it lowercase-normalised; one referrer can hold N codes. `redeem(code, referee_customer_id)` is single-use, refuses self-referral, refuses re-redemption, and emits both the referrer reward row + the referee reward row in one transaction. Reward shape is operator-config'd (`{ kind: 'percent_off' | 'amount_off' | 'points', value }`). Migration `0025_referrals.sql` (referral_codes + referral_redemptions tables, UNIQUE(referee_customer_id) so a customer can only be referred once, indexes on (referrer_customer_id), (status, created_at desc)). · *`notifications` primitive — queue with retry + per-customer channel preferences* — `bShop.notifications.create({ query?, cursorSecret? })` returns `{ enqueue, dispatch, markDelivered, markFailed, setPreference, getPreferences, listForCustomer, listPending }`. Channels: `email`, `sms`, `webhook`. A `notifications_preferences` row stores per-(customer_id, channel, category) opt-in state — `enqueue` consults the matrix and refuses with `{ skipped: true, reason: 'opted-out' }` when the matching row is opt-out. `dispatch` reads `next_retry_at <= now` rows in `pending` and walks the operator-supplied dispatcher callback. Failure schedules an exponential-backoff retry on `[60s, 5m, 30m, 4h]` before terminal `failed`. Migration `0024_notifications.sql` (notifications + notifications_preferences tables, indexes on (status, next_retry_at), (customer_id, created_at desc), (status, created_at desc)). · *`addresses` primitive — per-customer address book with default flags* — `bShop.addresses.create({ query? })` returns `{ add, update, get, listForCustomer, defaultShipping, defaultBilling, archive, unarchive, setDefaultShipping, setDefaultBilling, matchByContent }`. Default-flag uniqueness is enforced write-side — promoting an address to default clears the flag on its sibling rows in the same transaction. Archive drops both default flags so a stale archived row can't accidentally remain default. `matchByContent` collapses casing + whitespace on the address fields so dedup catches near-duplicates at submit time. Migration `0026_customer_addresses.sql` (id, customer_id, full_name, line1/line2, city, region, postal_code, country (ISO-3166-1 alpha-2), phone, is_default_shipping, is_default_billing, archived_at, created_at, updated_at). · *`taxExempt` primitive — exemption certificates with approve / reject / revoke pipeline* — `bShop.taxExempt.create({ query? })` returns `{ submit, approve, reject, revoke, expireScan, get, activeForCustomer, listPendingReview, isExempt }`. Cert numbers persist plaintext for operator display + dedup-hashed via `b.crypto.namespaceHash('tax-cert-number', uppercase(trim(...)))`. Submit is idempotent on `(customer_id, jurisdiction, hash)` — re-submitting an approved cert returns the existing row's status. `isExempt(customer_id, jurisdiction)` honours region-aware ancestry — an `US` certificate covers `US-CA` subdivision lookups. Read-path filters `expires_at <= now` so a slow `expireScan` scheduler can't honor a stale row at checkout. Migration `0027_tax_exempt_certs.sql` (certs + the FSM status column, indexes on (customer_id, status, expires_at), (status, submitted_at), (jurisdiction, status)). · *`emailSuppressions` primitive — bounce / complaint / unsubscribe / opt-out gate* — `bShop.emailSuppressions.create({ query?, cursorSecret? })` returns `{ add, isSuppressed, remove, byHash, list, cleanupExpired, stats }`. Suppression types: `bounce` (hard / soft), `complaint`, `unsubscribe`, `operator_manual`, `rate_limit_block`. Default scope is operator-friendly per type — bounce/complaint suppress `transactional`, unsubscribe suppresses `marketing`, operator-manual + rate-limit suppress `all`. `isSuppressed(email, scope)` honours scope hierarchy — an `all` row blocks every requested scope. Email never persists raw; only the lowercased + trimmed form + the `namespaceHash('email-suppression', ...)` digest. Migration `0028_email_suppressions.sql` (id, email_hash UNIQUE, email_normalised, type, scope, reason, occurrences, expires_at, created_at, updated_at). · *`currencyDisplay` primitive — FX rate cache + safe conversion + locale-aware format* — `bShop.currencyDisplay.create({ query? })` returns `{ setRate, getRate, convert, format, convertAndFormat, cleanupExpired, bulkSetRates }`. Rates encode as integer basis-points (`rate * 10000`) so storage stays integer-clean. Conversion composes `b.money.convert` — rounding is half-to-even via the framework's BigInt path, so zero-decimal targets (JPY, KRW) work without per-currency casing. Stale rows surface `{ stale: true, converted_minor: null }` instead of returning an out-of-date number — the storefront falls back to native currency. `bulkSetRates` validates every row before writing any (pre-flight, since D1 has no client transactions). Migration `0029_fx_rates.sql` (id, base_currency + quote_currency UNIQUE pair, rate_bps, source, fetched_at, expires_at). · *`searchSuggestions` primitive — operator-featured rows + popular-query log + product matches* — `bShop.searchSuggestions.create({ query?, catalog })` returns `{ recordQuery, suggest, addFeatured, updateFeatured, deleteFeatured, popularQueries, cleanupOldQueries }`. `suggest(prefix)` returns a three-array shape (`featured`, `popular`, `products`) so the storefront renders sectioned dropdowns without re-querying. Featured rows carry an FSM (`active` / `paused` / `expired`) + a `(starts_at, expires_at)` window so an operator can time a promotion. `link_url` on featured rows refuses `javascript:` / `data:` / `vbscript:` schemes. Session ids on `recordQuery` are hashed via `namespaceHash('search-suggestions-session', ...)` before write — the popular-query log never holds a recoverable identifier. Migrations `0030_search_suggestions.sql` (featured_search_suggestions + search_query_log tables, indexes on (active + window), (query_normalised, recorded_at desc), (priority desc)). · *`cartAbandonment` primitive — scheduled scanner over idle carts + reminder fan-out feed* — `bShop.cartAbandonment.create({ query?, cursorSecret? })` returns `{ scan, markReminderSent, markReminderSkipped, markReminderFailed, recentDetections, statsForRun, cleanupOld }`. Each `scan` writes a `cart_abandonment_runs` row + N `cart_abandonment_detections` rows for carts whose last update falls in the idle window. Idempotent — a cart already detected within the current idle window skips via a `last detection > idleCutoff` guard + a `UNIQUE(cart_id, detected_at)` collision catch. Session ids are namespace-hashed (`cart-abandonment-session`) before write. Reminder skip reasons route to `skipped-suppressed` (consult `emailSuppressions`) vs `skipped-no-email` (anonymous cart). Migration `0031_cart_abandonment_runs.sql` (detections + runs tables, indexes on (status, detected_at desc), (cart_id), (run_id)).

- v0.0.54 (2026-05-22) — **Reviews, wishlist, inventory-receive, webhooks DLQ, payment idempotency, newsletter unsubscribe, email templates, VAT/GST, midnight theme, analytics events.** Ten new or extended primitives land together. `reviews`, `wishlist`, and `inventoryReceive` are brand-new with their own migrations and surface APIs. `webhooks` gains a dead-letter queue + exponential-backoff retry + sliding-window rate-limit + signed-incoming verification. `payment` gains idempotency-key tracking so a re-issued Stripe call replays the stored response instead of double-charging. `newsletter` gains an unsubscribe token flow + resubscribe path. `email` gains three new transactional templates (wishlist-discount, abandoned-cart, review-request). `tax` gains VAT/GST extraction, reverse-charge for EU B2B, and format-only VAT-ID validation for 29 country codes. `analytics` gains an event-stream surface with hashed identifiers + top-N aggregations + funnel math. `themes/midnight` ships as an alternate dark-mode theme that inherits the default theme's component CSS and overrides only the design tokens. **Added:** *`reviews` primitive — moderated customer reviews per product* — `bShop.reviews.create({ query?, cursorSecret? })` returns `{ submit, get, publish, reject, listForProduct, summaryForProduct, byCustomer, hashCustomerId, hashCustomerEmail }`. Email is stored hash-only via `b.crypto.namespaceHash` namespace `"reviews-customer"`; raw addresses never persist. Either `customer_id` or `customer_email` is required at submit; the hash collapses casing for dedup. Status pipeline: `pending → published → rejected`. `summaryForProduct` returns count + average + distribution histogram. Migration `0011_reviews.sql` (id, product_id, customer_id, customer_id_hash, rating CHECK 1..5, title cap 120, body cap 4000, verified_purchase, status CHECK enum, created_at, updated_at) with indexes on (product, status, created_at desc), (customer_hash, created_at), and (product, rating). · *`wishlist` primitive — saved-for-later with operator analytics* — `bShop.wishlist.create({ query?, cursorSecret? })` returns `{ add, remove, listForCustomer, isWishlisted, countForProduct, popularProducts }`. Idempotent dedup via `UNIQUE (customer_id, product_id, COALESCE(variant_id, ''))`. `popularProducts` returns the top-N most-wishlisted products for operator merchandising. HMAC-tagged pagination cursor on `created_at` desc + `id` desc. Migration `0012_wishlist.sql` (id, customer_id, product_id, variant_id nullable, notes cap 280, created_at). 38 layer-1 check assertions covering add/dedup, scoped remove, pagination, tamper-refused cursor, distinct-customer count. · *`inventoryReceive` primitive — bulk stock receipts with audit trail* — `bShop.inventoryReceive.create({ query?, catalog, cursorSecret? })` returns `{ draft, apply, reverse, get, byReference, list }`. Operators draft a `pending` receipt with N lines in one transaction, then `apply` walks each line + calls `catalog.inventory.restock(sku, qty)` — atomic, so a single restock failure aborts the whole apply and leaves the receipt pending for retry. `reverse` rolls back an applied receipt by issuing the inverse stock adjustment. Idempotent re-apply on `applied` status is a no-op. Migration `0018_inventory_receipts.sql` (receipts + receipt_lines tables, FK CASCADE, indexes on received_at + status + receipt_id + sku). · *`webhooks` — dead-letter queue + exponential backoff + sliding-window rate-limit + signed incoming verification* — Failed deliveries now retry on a `[60s, 5m, 30m, 4h, 24h]` exponential schedule. After 5 failures, the row moves to `webhook_dlq` for operator investigation; `replayFromDlq(dlq_id)` re-queues with `attempts=0`. Per-endpoint `rate_limit_per_minute` (default 60) is enforced via a sliding-window count over the deliveries table. New `verifyIncoming(payload, signature_header, secret, tolerance_seconds?)` matches the Stripe `t=<unix>,v1=<hmac>` shape via `b.webhook.verify`; `signOutgoing(payload, secret, ts?)` is the companion emitter so the framework can both produce and verify the standard signature shape. Migration `0017_webhook_dlq.sql` adds the DLQ table + the next_retry_at + last_attempted_at columns on deliveries. · *`payment` — idempotency-key tracking on mutating Stripe calls* — `createPaymentIntent`, `refund`, and the `subscriptions.*` mutating methods now accept an `idempotency_key` parameter. When the primitive is created with a `query` opt, the same key + canonicalised body hash returns the stored response without re-calling Stripe; the same key with a *different* body throws `TypeError("payment: idempotency_key collision (different inputs)")` — silently returning would let an attacker replay a different amount. Migration `0015_payment_idempotency.sql` stores (idempotency_key PK, operation, request_hash, response_status, response_body, expires_at) with a 24-hour TTL matching Stripe's. `cleanupExpired()` purges expired rows on operator schedule. · *`newsletter` — single-use unsubscribe token + resubscribe flow* — `issueUnsubscribeToken(signup_id)` mints a 24-byte base64url plaintext bearer (32 chars), stores only the `namespaceHash("newsletter-unsubscribe", plaintext)` with a one-year expiry; the plaintext is returned once, never re-issuable. `consumeUnsubscribeToken(plaintext)` is the single-use redeem — structured-result return distinguishes `not-found`, `already-consumed`, `expired`, `ok`. Uses `b.crypto.timingSafeEqual` to equalize hit-vs-miss CPU work. `resubscribe({ email })` clears `unsubscribed_at` for a previously-opted-out signup. Migration `0014_newsletter_unsubscribe_tokens.sql` (token_hash PK, signup_id, created_at, consumed_at, expires_at). · *`email` — three new transactional templates* — `sendWishlistDiscount({ customer_email, product_title, product_url, old_price, new_price, discount_pct, expires_at? })` — price-drop alert with the orange CTA back to the product. `sendAbandonedCartReminder({ customer_email, customer_name?, cart_url, lines, total, notes? })` — loops cart lines through the strict renderer once per row so every cell stays HTML-escaped. `sendReviewRequest({ customer_email, customer_name?, order_id, products, review_base_url })` — per-product review links built as `review_base_url + "/" + slug + "/review"`. All three emit both html + text via `b.mail.compose`, validate addresses via `b.guardEmail`, and refuse unknown / unused placeholders at the strict renderer. · *`tax` — VAT / GST extraction + reverse charge + VAT-ID format validation* — `calculateInclusive({ amount_minor, rate_bps, currency })` extracts VAT from a gross price; `calculateExclusive` adds VAT to a net price; both use banker's rounding to even and guarantee `net + tax === gross`. `applyReverseCharge({ ctx, seller_vat_id?, buyer_vat_id?, buyer_country })` returns `rate_bps: 0` when both VAT IDs format-validate, both countries are EU, and the buyer VAT-ID country matches. `validateVatId(vat_id, country_code)` regex-checks against 29 country formats (EU-27 + GB + CH, with GR aliasing to EL). `format({ rate_bps, locale?, reverse_charge? })` renders via `Intl.NumberFormat` with optional reverse-charge annotation. Operators wire VIES live-validation out of band — the primitive's JSDoc documents the boundary. · *`analytics` — event-stream surface with hashed identifiers + top-N + funnel* — `recordEvent({ event_type, session_id?, customer_id?, product_id?, search_q?, page_url?, user_agent_class?, payload?, occurred_at? })` hashes `session_id` and `customer_id` via `b.crypto.namespaceHash` before write — raw identifiers never persist. Refuses raw email or IP shapes on every string field. `topSearchTerms`, `topViewedProducts`, `funnel`, `sessionFlow(session_id, …)`, `dropAfter(ts)` cover the operator surfaces. Migration `0019_analytics_events.sql` (id, event_type CHECK enum, session_id_hash NOT NULL, customer_id_hash, payload_json bounded 4 KiB, product_id, search_q, page_url, user_agent_class CHECK enum, occurred_at) + 4 indexes covering (event_type, time), (session, time), (product, event_type, time), and (search_q, time). · *`themes/midnight` — alternate dark-mode theme via design-token override* — 2.59 KiB stylesheet at `themes/midnight/assets/css/main.css` that `@import`s the default theme's component CSS, then overrides ~26 design tokens (palette flipped, shadow alpha deepened, accent warmed to `#ff8a3a` for dark contrast). Two per-surface fixes for `.site-header` and `.site-footer` which hard-code colours outside the token system. Operators activate by setting `SHOP_THEME=midnight` in wrangler vars. The midnight theme demonstrates the inheritance pattern for operators authoring their own theme.

- v0.0.53 (2026-05-22) — **Docs refresh, sample catalog tripled, designed empty-cart card, worker hardening.** Four surfaces refresh in one release. `SECURITY.md` + `CONTRIBUTING.md` + `docs/deploy-cloudflare.md` cover the scoped package name + custom domain + the full 0001-0010 migration table. The sample catalog grows from 4 products to 12 (Operator Hoodie, Vault Stick, Signing Cable, Build Pass, Audit Log Kit, Self-Hosted Plan, Operator Mug, Sticker Pack — each with its own brand-coloured SVG hero). The empty-cart row gains a designed `cart-empty` card with the brand 🛒 icon, eyebrow + title + lede + dual CTAs (`Browse products` primary, `Find a specific product` ghost linking the header search). The Worker gets a `/robots.txt` edge fallback (so crawlers get a clean answer even during container cold-start), the warming-up page picks up `noindex`/`canonical`/`aria-live`/refresh tuned to `Sec-Fetch-Mode`, and a new `/_/version` probe surfaces deploy state. **Added:** *Sample catalog grows from 4 to 12 products with brand-coloured SVG heroes* — `scripts/sample-product-images/{operator-hoodie,vault-stick,signing-cable,build-pass,audit-log-kit,self-hosted-plan,operator-mug,sticker-pack}.svg` ship as the next round of reference imagery. Apparel SVGs use dark-ink gradients with accent-orange illustrations; hardware uses navy-to-blue gradients with grey/black device silhouettes + accent-orange chip highlights; digital uses purple-to-navy with credential-artifact motifs carrying a visible PQC / ML-DSA seal; bundles use deep-orange or green with stacked-object compositions. All 800×800 viewBox, system font stack, monospace SKU at bottom-right. `scripts/seed-sample-products.sql` + `scripts/seed-sample-product-media.sql` extend with the matching catalog + media rows (UUIDv7 ids continue the existing numbering 0005-000c). · *Designed empty-cart card* — `renderCart` emits a `.cart-empty` section instead of a bare `<td colspan>` row when there are no lines. The card carries the brand emoji in a dashed-border circle, `Cart` eyebrow, `Your cart is empty` headline, a lede that explains how the cart holds the add-time price, and dual CTAs — `Browse products →` (primary, → `/`) + `Find a specific product` (ghost, anchors `#site-search-q` for header-search focus without inline JS). Populated cart gains a `Shop / Cart` breadcrumb above the section head, matching the PDP's pattern. · *Worker `/robots.txt` edge fallback* — Even during container cold-start, the Worker now serves a minimal `User-agent: *\nAllow: /\nSitemap: https://blamejs.shop/sitemap.xml\n` directly at the edge with a 1h cache. Crawlers never see the warming page for robots probes. R2-uploaded `/assets/robots.txt` overrides still win for operators with a custom policy. · *Worker `/_/version` deploy probe* — Operator-friendly diagnostic endpoint returning `{ worker, container_image, time }`. No auth required (pure read-only probe); use it to verify a deploy reached the edge before sending traffic. · *`CONTRIBUTING.md`* — 210 lines covering: dev environment setup (clone → vendor-update → smoke), the release workflow (release-notes JSON → CHANGELOG rebuild → branch → PR → admin merge → tag → publish), code conventions (CommonJS, `var`, zero npm runtime deps, compose `b.*` primitives, vendored tree read-only, security defaults non-opt-in, PQC-first), how to run + write tests (smoke + layer-0/1/2, `waitUntil` over `setTimeout`), the explicit list of artifacts each publish produces, and a pointer to `SECURITY.md` for vuln reporting. **Changed:** *Warming-up page — `noindex`, canonical, ARIA-live, refresh tuned to navigation mode* — The cold-start fallback page picks up `<meta name="robots" content="noindex, nofollow">` + a canonical link so crawlers don't index the placeholder. The auto-refresh interval shifts based on `Sec-Fetch-Mode` — 5 seconds for `navigate` requests (real visitor in a browser tab), 8 seconds for non-navigation fetches (XHR/fetch/Stripe.js probes that shouldn't spam the container). A new `aria-live="polite"` region announces the warming state to screen readers. · *`SECURITY.md` — Stripe webhook clause updated to match shipped code* — The container's defense-in-depth verification clause used a placeholder phrase from before `lib/payment.js` shipped. Now describes the actual `b.webhook.verify` call (alg `hmac-sha256-stripe`) running inside `lib/payment.js` before any FSM transition. Every other audit point matched the workflow output as-is — SLSA L3 provenance, Sigstore-keyless SBOM signatures, SHA-256 + SHA3-512 digests, ML-DSA-65 PQC sidecar. · *`docs/deploy-cloudflare.md` — custom-domain section + 0001-0010 migration table + demo-seed step* — Adds a `Wire a custom domain` section (zone-add → Worker custom-domain bind → `wrangler.toml` route alternative → TLS verify). Replaces the bare-paragraph migration mention with the explicit 0001-0010 table (calling out the intentional `0007` gap from the abandoned discounts work). Adds a `Seed demo content` section that runs both seed SQL files via `wrangler d1 execute --remote`. Switches the existing migration-apply step to `--remote` for clarity.

- v0.0.52 (2026-05-22) — **Live deploy moves to the custom domain — README + wrangler config point at https://blamejs.shop.** The reference deploy now serves at `https://blamejs.shop` instead of the `*.workers.dev` subdomain. README header now reads 'Homepage: **https://blamejs.shop**' and the admin-API curl example uses a placeholder `your-shop.example.com` host since operators replace it with their own. `wrangler.toml#D1_BRIDGE_URL` switches to `https://blamejs.shop` so the container's externalDb adapter calls back through the canonical origin. **Changed:** *README header — 'Homepage' instead of 'Live demo', custom domain* — `Homepage: **https://blamejs.shop**` replaces the previous `Live demo: **https://blamejs-shop.coocoo.workers.dev/**`. Operators evaluating the framework now see the canonical address. The admin-API curl example in the operator quick-start drops the placeholder `<your-worker>.workers.dev` host in favour of a `your-shop.example.com` placeholder with a comment pointing at the reference deploy. · *`wrangler.toml#D1_BRIDGE_URL` → `https://blamejs.shop`* — The container's externalDb D1 adapter posts SQL to `<D1_BRIDGE_URL><D1_BRIDGE_PATH>` (the Worker's service-binding bridge endpoint). Updating the base URL routes those internal calls through the custom domain. Cloudflare resolves custom domains to the same Worker that serves `*.workers.dev`, so the bridge keeps working with no router change.

- v0.0.51 (2026-05-22) — **SEO crawl surfaces — `/robots.txt` + `/sitemap.xml` listing every active product.** Crawlers landing on the live shop had no discovery surface — every product page was reachable only by being linked from the home grid, and there was no robots.txt to direct the crawl. This release wires `/robots.txt` (allow-everything-except-session-scoped, pointing at the sitemap) and `/sitemap.xml` (lists the home + /admin + every `status='active'` product with its `updated_at` lastmod, capped at 1000 rows). The XML is hand-rolled — no node:xml dep — with attribute-escaped values so a slug containing `&` or `<` can't break out of the document. **Added:** *`GET /robots.txt`* — `User-agent: *` + `Allow: /` for crawlable surfaces. Disallow paths cover the session-scoped + operator-only surfaces (`/admin`, `/cart`, `/checkout`, `/pay/`, `/orders/`, `/account`) — none of those have crawl value and exposing them risks crawlers tripping rate limits on the cart-bound DO. `Sitemap:` directive resolves from the request `Host` header so the same handler serves the right absolute URL for `localhost:8080` (dev), `blamejs-shop.coocoo.workers.dev` (live), or any custom domain. `Cache-Control: public, max-age=3600`. Operators with stricter requirements override by uploading a `robots.txt` key to R2 — the Worker's static-asset bridge serves R2 keys ahead of the storefront router, so the bucket file wins. · *`GET /sitemap.xml`* — Lists the home page (priority 1.0, daily changefreq), the `/admin` landing (priority 0.3, monthly changefreq), and every `status='active'` product (priority 0.8, weekly changefreq) with the product's `updated_at` or `created_at` as `lastmod` (ISO date). Capped at 1000 rows — the sitemap spec allows 50,000 but a shop at that scale should pre-segment into a sitemap index. The XML is hand-rolled, attribute-escape applies `&`, `<`, `>`, `"`, `'` so an operator-supplied slug can't break the document. `Cache-Control: public, max-age=300` so a catalog update propagates inside five minutes without an operator action.

- v0.0.50 (2026-05-22) — **OpenGraph + Twitter Card meta tags — shared links unfurl with proper previews.** Pages rendered without `og:*` or `twitter:*` tags. Sharing the live URL in Slack / iMessage / Twitter / Discord landed a bare URL with no preview, no image, no description. This release wires OpenGraph + Twitter Card meta tags into the LAYOUT head with sensible site-level defaults (brand logo + shop-level lede). The PDP overrides them with product-specific values — `og:title` is the product title, `og:description` is the catalog `description`, `og:image` is the first attached media URL — so a product share renders with the actual hero image + product copy. **Added:** *OpenGraph + Twitter Card tags in `LAYOUT`* — `<meta property="og:type">`, `og:site_name`, `og:title`, `og:description`, `og:image`, `og:url` + the Twitter Card analogues (`twitter:card=summary_large_image`, `twitter:title`, `twitter:description`, `twitter:image`). All templated through `{{og_*}}` placeholders so the `_render` HTML-escape protects against operator-supplied content breaking out of the attribute boundary. `<meta name="description">` also lands so non-OG crawlers see the same description. · *Default OG values on `_wrap(opts)`* — Every page renders with sensible defaults: `og:type=website`, `og:title=<page title> — <shop name>`, `og:description=Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.`, `og:image=/assets/brand/logo.png`. Per-renderer overrides via `opts.og_*` work surgically — only the field passed in overrides; the rest stay on defaults. · *Product-specific OG on the PDP* — `renderProduct` overrides four OG fields: `og_type=product`, `og_title=<product.title> — <shop_name>`, `og_description=<product.description>` (or a generated fallback when the description is empty), `og_image=<assetPrefix><heroMedia.r2_key>` (the first attached media row). A PDP share now renders the SVG hero + the product copy, not the brand logo + a generic shop description.

- v0.0.49 (2026-05-22) — **README refresh — scoped install command, live-demo URL, refreshed primitive table + migration list.** The README hadn't been touched since v0.0.43; the install snippet still pointed at the unscoped `blamejs-shop` name and the primitives table listed only the original commerce surface. Operators landing on the GitHub repo now see the correct `npm install @blamejs/blamejs-shop`, the live demo URL, badges for npm + license + SLSA L3, and a primitives table that includes `customers`, `subscriptions`, `newsletter`, and `theme` alongside the original commerce stack. **Changed:** *README — install snippet uses `@blamejs/blamejs-shop`* — Replaces the unscoped clone-and-smoke recipe with `npm install @blamejs/blamejs-shop` + the `require()` shape that resolves the framework + adapters. The local clone path stays as the secondary recipe for operators who want to develop against the repo. Three badges land in the header — npm version, Apache-2.0 license, SLSA Level 3. · *README — primitive table extended* — Adds rows for `customers` (passkey-only, email hash-only), `subscriptions` (Stripe-backed recurring), `newsletter` (signup primitive shipped in v0.0.41), and `theme` (file-backed templates). The `storefront` row gains the full surface description — utility bar, sticky header, dark hero with code-preview card, marquee, featured-product callout, collections grid, framework feature band, designed catalog grid, newsletter band, and the four-column footer. `admin` row mentions the subscription-plan routes. · *README — migration list updated* — Lists migrations 0001-0010 (catalog, cart, order, shop_config, webhooks, customers, inventory_thresholds, subscriptions, newsletter_signups) so operators don't have to grep the directory to know what schema lands. Adds the demo-seed recipe (`scripts/seed-sample-products.sql` + `scripts/seed-sample-product-media.sql`) for one-shot population of the live demo's four products + their SVG hero images. · *Live-demo URL pinned* — `https://blamejs-shop.coocoo.workers.dev/` lands in the README header so operators evaluating the framework can see a real deployed instance rendering the storefront design system end-to-end before committing to wiring their own Cloudflare account.

- v0.0.48 (2026-05-22) — **Home page — featured-product callout between the primitives marquee and the collections grid.** The home page rhythm went marquee → 6-tile collections grid → framework band, so a single product never got more than the four-tile catalog card's surface area to introduce itself. This release inserts a split callout (square hero image on the left, copy column with `Featured` eyebrow + title + description + accent-coloured price + `View product →` CTA on the right) between the marquee and the collections grid. The render picks the first product with attached media; operators wrapping `renderHome` can override the selection via `opts.featured`. **Added:** *`.featured-product` section* — Sits between the primitives marquee and the collections grid. Two-column grid at desktop (square hero + copy column), stacks to image-above-copy at <64rem with a 16/10 image aspect ratio. Title is a `clamp(1.75rem, 3vw, 2.5rem)` headline; price renders in the display font at `--text-2xl` with the accent colour + tabular-nums. Image is wrapped in an anchor to the PDP and has a hover-scale of `1.03` driven through the existing `--duration-slow` ease curve. The card itself has a hairline border on `var(--paper)` so it reads as a content tile, not a hero band — keeps the dark hero above and the framework band below as the page's main rhythm changes. · *`storefront.renderHome({ ..., featured? })` selection override* — When `opts.featured` is supplied the renderer uses it verbatim (shape: `{ title, description, price, slug, image_url, image_alt }`). When absent, the renderer scans the active-products list for the first row with `image_url` and uses that as the featured pick. Products without media are skipped; when no product has media the callout renders as an empty string and the page rhythm falls back to marquee → collections directly. **Changed:** *Product data shape — `description` carried through* — The renderHome `products.map(...)` projection now includes `description` (from the catalog products row) so the featured callout's lede can pull product-specific copy. Existing callers that didn't pass a description continue to render — the callout falls back to a stock 'Server-rendered, PQC-secured, shipped from origin' line.

- v0.0.47 (2026-05-22) — **Account dashboard — 4-stat row + recent-orders table with per-order product thumbnails.** The dashboard surfaced a bare three-column orders table (order id, status, total). This release adds a 4-stat row above the body (orders / lifetime spend / member since / passkeys) and a new 'Items' column on the orders table that renders the first four product thumbnails per order with a `+N` overflow chip when there are more. Visitors land on a richer surface that summarises their relationship with the shop instead of just listing opaque order ids. **Added:** *Stats row — orders, lifetime spend, member since, passkeys* — Four-up grid above the orders table. `Orders` is the count of returned orders (capped at 10 by the listForCustomer page; future patch could surface the true count via a `count()` shape on the order primitive). `Lifetime spend` sums `grand_total_minor` across orders in the dominant currency (falls back to '—' if multiple currencies appear, since silently mis-summing them is worse than declining to compute). `Member since` reads `customer.created_at` when present, otherwise the earliest order's `created_at` — both as an ISO date. `Passkeys` calls `deps.customers.listPasskeys(customer.id)` (drop-silent on `TypeError` if the primitive build doesn't expose the listing surface yet). · *`Items` column — per-order product thumbnails* — Per-order, the route walks `order.lines`, collapses to unique variant ids, and resolves each through cached `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The variant cache is held across orders so a customer with multiple orders containing the same SKU only hits the catalog once. The table cell surfaces the first four thumbs (`.account-order__thumb`, 2.5rem rounded tiles); orders with more than four show a `+N` overflow chip. Status renders as the same `.pdp__badge`-style pill the order page uses — green-bordered `--ok` variant for `completed` / `shipped` / `delivered`. **Changed:** *`storefront.renderAccount({ customer, orders, order_product_lookup?, passkey_count?, asset_prefix? })`* — New optional opts. `order_product_lookup` is `{ order_id: [{ product, hero_media }, ...] }` — the route bundles it in. `passkey_count` is the integer count of enrolled passkeys (0 when the customers primitive can't enumerate them on this build). `asset_prefix` defaults to `/assets/` for image URL composition. All three are optional — calling with just `customer` + `orders` continues to render cleanly, the stat row just shows `0` / `—` for the missing fields.

- v0.0.46 (2026-05-22) — **Order confirmation page — line items render thumbnails + product titles (same pattern as the cart).** The post-checkout `/orders/:order_id` page used the read-only `CART_LINE` template that emitted just SKU + qty + unit + total. The order confirmation now reuses the same product-cell treatment v0.0.45 shipped on the live cart — small thumbnail (from `catalog.media.listForProduct`) + product title + SKU chip in a slug-linked anchor — so the page a customer lands on after a successful purchase shows what they bought visually, not just a column of opaque SKU codes. **Changed:** *`CART_LINE` (read-only, used by the order page) — same product cell as the editable cart line* — Markup mirrors `CART_LINE_EDITABLE` minus the qty / update / remove forms. First cell is `<a class="cart-line__product-link" href="/products/<slug>">` wrapping the thumbnail + title + SKU chip; remaining cells (qty / unit / total) are unchanged. Reuses the existing `.cart-line__thumb` + `.cart-line__product-*` CSS — no new theme rules. · *`storefront.renderOrder({ ..., product_lookup })` accepts the variant→product map* — Mirrors `renderCart`'s contract. The route handler bundles a `{ variant_id: { product, hero_media } }` cache built per-unique-variant; lines whose variant is missing fall through to SKU-as-title + the placeholder tile. The order page table header is now 'Product' instead of 'SKU'. · *`GET /orders/:order_id` walks the order's frozen lines once* — After loading the order, the route walks `o.lines` and for each unique `variant_id` pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The cache prevents a multi-quantity-same-variant order from hitting the catalog more than once per variant. The lookup is passed to `renderOrder` so the line rendering matches the cart's pattern byte-for-byte.

- v0.0.45 (2026-05-22) — **Cart line items — thumbnail + product title + slug-linked anchor alongside the SKU chip.** Cart rows previously rendered only the SKU code, qty, unit, and total — the visitor had to remember what they actually added. Each line now leads with a 3.5rem rounded thumbnail (from the product's first `catalog.media` row), the product title in a bold inline label, and the SKU as a small monospace chip below. The whole product cell is an anchor back to the PDP so a visitor can re-enter the product page directly from the cart. Lines whose variant is missing media render the placeholder tile (dashed-border + the diagonal-stripe pattern the catalog empty-state uses). **Changed:** *`CART_LINE_EDITABLE` template — product cell replaces SKU-only cell* — First cell is now `<a class="cart-line__product-link" href="/products/<slug>">` wrapping a thumbnail + a `<span class="cart-line__product-meta">` that stacks the product title (bold) and SKU chip (monospace, on a `--bg-2` background). Hover on the link tints both the title and the link affordance to the accent color. The qty / unit / total / action cells are unchanged. · *`storefront.renderCart({ ..., product_lookup })` accepts a variant→product lookup* — Routes pass a `{ variant_id: { product, hero_media } }` map; lines render with the matching product's title + slug-linked URL + hero-media thumbnail. Lines without a lookup match (variant deleted, media not attached yet) fall through to a SKU-as-title display with the placeholder tile. The cart-table column header is now 'Product' instead of 'SKU'. · *`GET /cart` builds the product lookup* — After listing the cart's lines, the route walks each unique `variant_id` (cached by id so a cart with the same variant twice only hits the catalog once), pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`, and bundles the result for the renderer.

- v0.0.44 (2026-05-22) — **Package rename — `@blamejs/blamejs-shop` (scoped under the org alongside `@blamejs/core` + `@blamejs/exceptd-skills`).** The npm package is now published under the `blamejs` organization scope as `@blamejs/blamejs-shop`. Installation switches from `npm install blamejs-shop` to `npm install @blamejs/blamejs-shop`; the import surface is unchanged — `require("@blamejs/blamejs-shop")` returns the same shop primitive set the unscoped name did. The legacy unscoped package will be deprecated on the registry with a pointer to the new name so existing operators see the upgrade path on their next install. **Changed:** *`package.json#name` → `@blamejs/blamejs-shop`* — Matches the existing org-scoped packages (`@blamejs/core`, `@blamejs/exceptd-skills`). The publish workflow already passes `--access public` to `npm publish`, which is the access mode scoped packages need to land as publicly-installable. · *Install command* — `npm install @blamejs/blamejs-shop` replaces `npm install blamejs-shop`. The `require` path follows: `var bShop = require("@blamejs/blamejs-shop");`. The entry point + export shape are byte-for-byte identical to v0.0.43 — only the resolution path changes. **Migration:** *Operator upgrade path* — Operators on the unscoped `blamejs-shop` change one line in their `package.json` dependencies: `"blamejs-shop": "^0.0.43"` → `"@blamejs/blamejs-shop": "^0.0.44"`. Every `require("blamejs-shop")` call site needs the matching update (`require("@blamejs/blamejs-shop")`). The unscoped name will be deprecated on the registry with a pointer to the new scoped path so a fresh `npm install` on the old name surfaces the migration message in npm's stderr.

- v0.0.43 (2026-05-22) — **Search results — image-bearing product cards (consistent with the home grid).** The search results page rendered text-only `PRODUCT_CARD` tiles — title + price + 'View product →' link — while the home catalog grid (v0.0.42) shifted to clickable image-bearing `product-card` tiles. This release unifies the two surfaces: search results now go through the same `_buildProductCard(p)` picker so a hit lands a real hero-image tile when the product carries media, and the text-only fallback when it doesn't. The same `asset_prefix` override the home renderer accepts also works on the search renderer. **Changed:** *`storefront.renderSearch({ products, asset_prefix? })` uses `_buildProductCard`* — Same render-time picker the home grid uses — when a result product carries `hero_media`, the card renders as the image-bearing tile; without media, the text-only fallback. `asset_prefix` defaults to `/assets/` and can be overridden for operators on a different R2 binding or CDN remap. · *Search route fetches `catalog.media.listForProduct` per hit* — `GET /search` now calls the media lookup alongside the existing variants + prices loop. The first attached media row becomes `product.hero_media` on the data shape `renderSearch` consumes — same shape the home route v0.0.42 emits.

- v0.0.42 (2026-05-22) — **Home catalog grid — product cards now render the hero image alongside title + price.** The home page catalog grid used text-only cards (`<h2>` + price + 'View product →' link). When a product carries at least one `catalog.media` row, the card now renders as a clickable image-bearing tile — square hero image at the top, then a single-row meta strip with title + accent-colored price. Products without media keep the existing text-only card so a freshly-listed product with no images yet renders cleanly. **Added:** *`PRODUCT_CARD_IMAGE` template + `_buildProductCard(p)` picker* — When a product carries an `image_url`, the renderer emits an `<a class="product-card">` wrapper with a square `<figure class="product-card__media">` hero (`object-fit: cover`) above a meta strip holding the title and price. Hover lifts the card and darkens the border. When no media exists the existing `PRODUCT_CARD` text-only tile is the fallback. The picker keeps the template engine's HTML-escape behavior — `image_url` + `alt_text` flow through `_render`, never bypassing the escape. · *Home route fetches media per product* — `GET /` now calls `deps.catalog.media.listForProduct(product.id)` alongside the existing `variants.listForProduct` + `prices.current` lookups. The first media row becomes `product.hero_media` on the data shape `renderHome` consumes; products with no attached media stay at `hero_media = null` and the renderer drops back to the text-only card. **Changed:** *`storefront.renderHome({ products, asset_prefix? })` — `asset_prefix` defaults to `/assets/`* — Image URLs are built as `assetPrefix + p.hero_media.r2_key` so operators on a custom asset prefix (different R2 binding, CDN remap) can override without forking the renderer. Backward-compatible — when `asset_prefix` is omitted the default lands at the Worker's `/assets/*` static-asset bridge.

- v0.0.41 (2026-05-22) — **Newsletter signup primitive — POST `/newsletter` enrolls a visitor, designed thank-you page on either branch.** The storefront footer + newsletter band already rendered an email form that posted to `/newsletter`, but no handler was mounted — submitting the form 404'd. This release ships a `newsletter` primitive (`lib/newsletter.js`) that composes `b.guardEmail` for RFC-shape validation, `b.crypto.namespaceHash` for dedup, and stores rows in `newsletter_signups` (new migration `0010_newsletter_signups.sql`). The storefront's POST `/newsletter` route hands the form payload to the primitive and renders a designed thank-you card on success — with separate copy for the `new` (just enrolled) and `dedup` (already on the list) branches — and a designed error card on validation refusal. **Added:** *`newsletter` primitive (`lib/newsletter.js`)* — `bShop.newsletter.create({ query? })` returns `{ signup, byEmailHash, count, EMAIL_NAMESPACE }`. `signup({ email, source })` normalises the email (trim + lowercase), validates via `b.guardEmail(addr, { profile: 'strict' })`, hashes through `b.crypto.namespaceHash('newsletter-email', normalised)` for the dedup key, and runs an `INSERT OR IGNORE` so a repeat signup is an idempotent no-op. Returns `{ id, status: 'new' | 'dedup', email_normalized }`. `byEmailHash(hash)` is the operator lookup for an unsubscribe flow. `count()` returns the active signups for an optional live newsletter-band stat. · *Migration `0010_newsletter_signups.sql`* — New table — `id` UUID PRIMARY KEY, `email_hash` UNIQUE (the namespaceHash dedup key), `email_normalized` (the canonical address an operator needs to actually send a message; D1's encrypted-at-rest default + the operator's access controls cover the PII posture), `source` TEXT (defaults to `storefront-footer`, validated against `/[a-z0-9][a-z0-9._-]*[a-z0-9]/`), `created_at` epoch ms, `unsubscribed_at` NULL until the user opts out (the opt-out flow ships in a follow-up; the column lands now so existing rows don't need a schema rewrite). Two indexes — `created_at` for time-range broadcasts, `source` for channel-attribution filters. · *Storefront route — POST `/newsletter`* — Mounts only when `deps.newsletter` is wired (operators that don't construct the primitive get a clean 404 instead of a misleading 200). Catches `TypeError` (input refusals — bad email format, oversized source string, control bytes) and renders the designed error card with HTTP 400; everything else (D1 unreachable, vault hiccup) renders the same card with HTTP 500. Success renders the thank-you card with HTTP 200 and adapts the copy to the `new` vs `dedup` branch. · *Designed thank-you + error cards* — `renderNewsletterThanks(opts)` and `renderNewsletterError(opts)` both wrap the layout shell with an `eyebrow` + centred title + lede + dual-CTA card (`Back to the shop →` primary, `Star on GitHub` ghost). The `dedup` branch reads 'You're already on the list.' so a double-submit isn't a silent failure. **Changed:** *`server.js` wires `bShop.newsletter.create({})` into `sfDeps`* — Constructed alongside `customers` inside the `if (catalog && cart)` block; the primitive only needs `b.externalDb.query` which the deploy already provisions via the Worker's `D1_BRIDGE_URL`. · *`lib/index.js` exports `newsletter`* — Same shape as the rest of the shop primitives — `var n = bShop.newsletter.create({});`. **Detectors:** *Layer 1 coverage continues to gate input refusal at every entry point* — The newsletter primitive defers shape checks to `b.guardEmail` (already covered by the framework's own test suite) and `b.crypto.namespaceHash` (ditto). The shop layer's contribution is `_normalizeSource` (regex + length cap) and the idempotent dedup flow; the layer-1 smoke surface continues to refuse missing-input + wrong-type + over-length cases on every public entry point in the shop primitives.

- v0.0.40 (2026-05-22) — **Product detail page — gallery wired to real `media` rows with a branded SVG placeholder fallback.** The PDP gallery used to render a giant initial-letter mark as a placeholder regardless of what the product looked like. The storefront now reads `catalog.media.listForProduct(product.id)` on every PDP request — when at least one media row exists, the first row drives the hero `<figure>` (with `alt_text` for a11y) and up to three more rows feed the thumbnail strip. The letter-mark falls through as a clean default when no media has been attached yet. A demo-seed pair (`scripts/seed-sample-product-media.sql` + four brand-colored SVG images under `scripts/sample-product-images/`) ships alongside the existing sample-products seed. **Added:** *`storefront._buildPdpGallery(product, media, assetPrefix)`* — Composes the PDP hero figure + thumbnail strip from `catalog.media` rows. When `media` is non-empty, the first row becomes `<figure class="pdp__media pdp__media--image"><img src="<asset_prefix><r2_key>" alt="<alt_text>"></figure>` (with `loading="eager"` since it's above-the-fold), the next three drive thumbs, and missing thumb slots render as dashed-border placeholders so a single-image product still occupies the four-slot grid. When `media` is empty the gallery falls back to the letter-mark placeholder so a freshly-seeded product never renders an empty square. All template substitutions go through `_render`'s HTML-escape; URLs and alt text are attribute-escaped via a local `_escAttr` helper so an operator-supplied `r2_key` containing `"` or `<` lands as escaped text inside the attribute. · *`scripts/seed-sample-product-media.sql`* — Idempotent media seed: one `media` row per seeded product pointing at `products/<slug>.svg` with `content_type = image/svg+xml`, 800×800, `position = 0`, and human-readable `alt_text`. Run alongside the existing `scripts/seed-sample-products.sql` via `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-product-media.sql`. Drop the rows with `DELETE FROM media WHERE id LIKE '00000000-0000-7000-8000-0000000000%';` when replacing with operator-supplied media. · *`scripts/sample-product-images/` — reference SVGs* — Four 800×800 SVG product placeholders matching the seeded catalog: `operator-tee.svg` (charcoal + accent shield silhouette + tee mark), `edge-reader.svg` (navy + hardware silhouette with status LEDs + monospace label), `operator-license.svg` (purple + ML-DSA-signed card mockup), `starter-bundle.svg` (orange gradient + stacked-box composition representing the three component SKUs). Each uses only the design-system palette + system font stack — no external font fetches, no raster assets. Operators copy these into their own R2 prefix as a starting point or replace them entirely; the demo seed expects them at R2 key `products/<slug>.svg`. **Changed:** *`storefront.renderProduct({ product, variants, prices, media, ... })`* — Accepts an optional `media` array of media rows. When supplied, the gallery renders the real image; when omitted (the current contract for callers that don't read media — including the layer-1 test fixtures), the gallery renders the letter-mark placeholder. Backward-compatible — existing callers that don't pass `media` continue to render the placeholder, exactly the v0.0.39 behavior. The PDP storefront route (`GET /products/:slug`) now calls `deps.catalog.media.listForProduct(product.id)` and threads the result through. · *Theme CSS — `.pdp__media--image` variant* — When the gallery hero is a real image, the `--image` modifier swaps the gradient placeholder backdrop for `var(--paper)`, removes the orange radial-glow `::after` decoration, and lets the `<img>` fill the square via `object-fit: cover`. Thumbnail `<img>` elements get the same `object-fit: cover` treatment so the strip stays uniform across portrait and landscape source images.

- v0.0.39 (2026-05-22) — **Publish workflow — retry the post-publish `npm view` so CDN propagation delay doesn't surface as a red X.** The post-publish `Verify the version landed on the registry` step ran `npm view <pkg> version` immediately after `npm publish`. The npm registry CDN takes several seconds to publish a fresh manifest, so the immediate read returned a 404 even on a successful publish — leaving the otherwise-green release run with one red X. Replaced with a six-attempt retry loop (6 second backoff, ~36s total budget) that exits cleanly the moment the registry serves the target version. **Fixed:** *Post-publish registry-verify retry loop* — The single-shot `npm view` after `npm publish` raced the npm registry's CDN — the publish call returned 200, the package landed, but the manifest GET endpoint still returned 404 for several seconds. The verify step now polls `npm view <pkg> version` up to six times with a six-second backoff (~36 seconds total budget). It exits success the moment the registry returns the just-published version, and only flags a real mismatch (`exit 1`) when the propagation never lands inside the budget.

- v0.0.38 (2026-05-22) — **Publish workflow — install devDeps before `npm sbom` so the runtime-dep tree resolves.** `npm sbom` walks the installed dep tree; without `node_modules` it errors `ESBOMPROBLEMS missing: <devDep>` even when `--omit=dev` would have excluded the devDep from the output. The validate job now runs `npm install --no-audit --no-fund --ignore-scripts` before the SBOM step so the tree resolves; the `--omit=dev` filter on `npm sbom` continues to keep the SBOM clean. **Fixed:** *`npm install` before `npm sbom` in the publish workflow* — The SBOM step was running against a missing dep tree. Added `npm install --no-audit --no-fund --ignore-scripts` so the local install resolves before `npm sbom --omit=dev` walks it. The zero-runtime-deps invariant check downstream is unchanged — it counts components in the emitted SBOM, which `--omit=dev` keeps at zero.

- v0.0.37 (2026-05-22) — **ESLint configuration — flat-config + bug-class rules so the publish workflow's lint gate runs.** ESLint 9+ requires a flat-config file at the repo root. The publish workflow's `npx eslint@latest --max-warnings 0 .` step couldn't find one and exited with `ESLint couldn't find an eslint.config.(js|mjs|cjs) file`. Added `eslint.config.mjs` configured for the project's posture (Node 24 LTS, CommonJS, ES2024) and pruned four pre-existing warnings the new config surfaced — three unused-variable hygiene fixes, one `var config` redeclaration in `server.js`, and one `_cartCountForReq` hoisting inside `lib/storefront.js` so the `/admin` landing + catch-all `onNotFound` handler can reach it regardless of `deps.customers` wiring. **Added:** *`eslint.config.mjs` — flat-config* — Bug-class rule set mirroring the vendored framework's posture: `no-undef`, `no-redeclare`, `no-const-assign`, `eqeqeq` (with the `== null` idiom carve-out), `no-this-before-super`, `no-throw-literal`, `no-unused-vars` (underscore-prefix exception). Stylistic rules (`no-useless-escape`, `no-promise-executor-return`, `no-prototype-builtins`, `no-control-regex`) are intentionally `off` — they're idiomatic patterns the codebase relies on, not bug signals. Three config blocks: CJS `.js` (default), ESM `.mjs`, and Cloudflare Workers `worker/**/*.js` with the Workers runtime global set. Ignores cover the usual `node_modules`, `lib/vendor`, `.test-output`, `.scratch`, `.template`, `.wrangler`, `.playwright-mcp`, and `.claude` worktree paths. **Fixed:** *`_cartCountForReq` hoisted to mount-scope* — The helper that reads `shop_sid` and returns the cart-line count for the header pill was nested inside the `if (deps.customers)` block at the bottom of `storefront.mount`. The `/admin` landing handler and the `onNotFound` catch-all 404 (both added in v0.0.35, mounted outside that block) reached for `_cartCountForReq` and got a `ReferenceError` at runtime when `deps.customers` was absent — in production both deps are wired together so the path never fired, but the path was real. The helper now lives at the top of `mount()` so every handler can reach it. · *`server.js` — duplicate `var config` removed* — `var config = catalog && cart ? bShop.config.create({}) : null;` at the top of the routes function already binds `config` for the whole scope. A second `var config = bShop.config.create({});` inside the admin-mount block was a JavaScript no-op (var declarations don't shadow inside nested blocks) but a real lint signal. Removed; the admin block reuses the outer handle. · *Test-scope unused locals + admin checkout placeholder* — Three values — `var now`, `var bad`, and `var checkout` — were assigned but never read in `test/layer-1-state/analytics.test.js`, `test/layer-1-state/catalog-import.test.js`, and `lib/admin.js` respectively. Renamed with the underscore prefix the project uses for intentional unused locals (`_now`, `_bad`, `_checkout`) so the `no-unused-vars` rule's allowlist matches them.

- v0.0.36 (2026-05-22) — **Publish workflow YAML — refactor the SLSA download-artifact step out of flow mapping so the parser accepts it.** The release `npm-publish.yml` workflow couldn't start. `download-artifact` for the SLSA provenance file used YAML flow-mapping form (`with: { name: blamejs-shop-${{ … }}.intoto.jsonl }`) and the parser read the `}}` inside the GitHub expression as the flow-collection close. Rewritten as block form, the workflow parses and the publish pipeline runs. **Fixed:** *`npm-publish.yml` — `download-artifact` step parses correctly* — The SLSA provenance download step at the publish job mixed YAML flow-mapping `{ … }` with a GitHub `${{ … }}` expression. The parser hit the `}}` inside the expression as a flow-mapping close, rejecting the whole document at parse time (no jobs ever materialised — the run surfaced as `startup_failure`). Rewriting the step as block form (`with:` / `  name: …` on its own line) fixes the parse error. No semantic change to the workflow.

- v0.0.35 (2026-05-22) — **Storefront redesign — multi-section landing, designed cart / PDP / checkout, theme CSS extracted to an external stylesheet.** Every storefront page now ships through the same design system — utility bar, sticky header with search + cart pill, dark hero with code-preview card, primitives marquee, collections grid, framework feature band, designed catalog empty state, newsletter band, and a four-column footer. Cart, product, checkout, pay, order, account login / register / dashboard, search, /admin, and 404 each got their own designed treatment. The theme's design tokens moved out of inline `<style>` blocks (which `style-src 'self'` blocks) into `themes/default/assets/css/main.css`, served same-origin from R2 with a build-time version cache-buster. Operators override the look by uploading a replacement CSS at the same R2 key or by passing `theme_css` to the storefront renderers. **Added:** *`/admin` landing page* — Operators (and the footer link) now land on a designed HTML page at `/admin` that explains the API-only posture and shows four curl examples (create a product, search products, restock inventory, issue a refund) as syntax-highlighted code cards, plus a sidebar note linking to `lib/admin.js` on GitHub. Every other `/admin/*` route remains a bearer-token-gated JSON endpoint. The HTML route only mounts inside the storefront's standard mount; no admin API behavior changed. · *Designed 404 page via `router.onNotFound`* — Every unmatched route — `GET`, `POST`, anything — now renders a designed not-found page (gradient `404` numeral + 'That page slipped the catalog.' copy + Back-to-shop + View-your-cart CTAs) inside the standard layout, instead of the framework's default `<h1>404 Not Found</h1>` text body. Wired through `router.onNotFound`, so it covers paths and verbs uniformly. · *Live hero stat — `Products live` count* — The first stat in the hero now reflects the actual catalog size (the count of `status='active'` products returned to the home renderer). Empty catalogs show an em-dash. The remaining three stats stay hardcoded brand-facing values (`npm runtime deps: 0`, `Default crypto: PQC`, `License: Apache 2.0`). · *Customer accounts wired into storefront mount* — `server.js` constructs `bShop.customers.create({})` and threads it into `sfDeps.customers`, opting the `/account/login`, `/account/register`, `/account` dashboard, and the passkey ceremony JSON endpoints into the deploy. The customers primitive itself shipped in `v0.0.28` but its routes were never mounted in the storefront. · *`scripts/seed-sample-products.sql` — demo catalog seed* — One-shot SQL seed for the demo storefront: four products (Operator Tee, Edge Reader v1, Operator License, Starter Bundle), four variants, four current-price rows, and an inventory row each. Run with `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-products.sql`. Uses `INSERT OR IGNORE` so re-running is idempotent. Operators replace this with their own catalog through the admin API once they have one. **Changed:** *Storefront layout — utility bar + designed header + multi-column footer* — `LAYOUT` in `lib/storefront.js` now wraps every page in a thin top utility bar (live status pill, primitive strapline, GitHub call-to-action), a sticky header with the wordmark logo + pill search input + Shop / Framework nav + circular account icon + cart pill with count badge, and a four-column footer (brand + GitHub / npm / RSS social, Shop, Framework, Operators) plus a bottom strip with copyright + Security / Privacy / Terms links. The same shell wraps every page renderer. · *Home page composition* — Six sections, all server-rendered. Dark hero with eyebrow + version pill, headline with accent on `nothing`, lede, primary + ghost CTAs, a four-stat row, and a tilted glass code-preview card showing a `bShop.checkout.finalize(…)` snippet with syntax highlighting. A CSS-only infinite-scroll marquee lists twelve framework primitives (ML-KEM-1024, ML-DSA-65, XChaCha20-Poly1305, SHAKE256, Argon2id, SLSA L3 provenance, Sigstore-keyless SBOM, Trusted Types, Stripe-first payments, WebAuthn passkeys, server-rendered HTML, zero npm runtime deps). A six-tile collections grid (Apparel, Hardware, Digital, Subscriptions, Bundles, Gift cards) with per-tile gradient art and hover-arrow circles. A dark framework feature band with split copy + four numbered feature cards. A catalog section that renders product cards when seeded and a designed empty state otherwise — dashed-placeholder tile grid + 'Add the first product' copy + a literal `curl POST /admin/products` snippet. · *Product detail page* — Two-column layout with breadcrumb (Shop / Product), a square gallery placeholder showing the product initial as a large semi-transparent mark with an orange radial glow + four thumbnail squares, and an info column with eyebrow + title + description + three status badges (In stock, Ships from origin, Stripe-secured checkout) + a variant table with title / SKU code chip / accent-colored price / qty input + Add to cart button. The mobile breakpoint stacks the gallery above the info column and hides the SKU + Action header columns on narrow viewports. · *Cart / checkout / pay / order page redesigns* — Cart page is a two-column grid: line table on the left (SKU code chip, qty input with Update button, unit + line price, Remove button), sticky Order Summary card on the right (totals list with grand-total row, full-width Continue to checkout CTA, tax + shipping note). Checkout form is a vertical form-stack with grouped uppercase labels, inline country / state / postal row, and a designed Order Summary panel. Pay page hosts the Stripe Payment Element inside a designed card with a full-width Pay button + payment message line. Order confirmation shows a status badge (`In stock`-style chip) + a two-column items / totals layout. Every form input + button + table now uses the shared design tokens. · *Account login / register / dashboard* — Login + register are centered single-column auth cards with the design tokens — eyebrow + title + lede + form-stack + full-width submit + auth-message line + alt link (`New here?` / `Already have one?`). The passkey ceremony JS is unchanged. Dashboard shows a head row (eyebrow + name + lede on the left, Sign-out button on the right) followed by a recent-orders table in a designed body card. · *Search page* — Search now opens with a left-aligned section head (eyebrow + dynamic title + summary + `All products →` shortcut) followed by either the product grid or a dashed-border empty card with a magnifier icon + `We don't carry that yet` + `Browse the full catalog` CTA. The empty state copy adapts to a blank query (`Use the search box in the header to look for a product…`) vs a no-match query (`Nothing in the catalog matched …`). · *Default theme stylesheet — extracted to `themes/default/assets/css/main.css`* — The 346-line inline `<style>` block in the layout is gone. Every design token (palette, type scale, spacing, radius, shadow, motion, container width), reset, atom (eyebrow, link-arrow, btn-primary, btn-ghost, btn-secondary, dot), and component (header, utility bar, cart pill, hero, marquee, collections, framework band, value cards, PDP, cart, checkout, pay, order, auth, account dash, search, 404, admin landing, newsletter, footer) ships from `themes/default/assets/css/main.css` (~12 KB). The layout references it as `<link rel="stylesheet" href="/assets/themes/default/css/main.css?v=<package-version>">`. The Worker's static-asset bridge passes the request straight to R2 with `Cache-Control: public, max-age=300`. Operators override by uploading their own stylesheet to the same R2 key, or by passing `opts.theme_css` to any storefront renderer. · *Worker cold-start envelope + warming-up fallback page* — `worker/index.js` bumps `sleepAfter` from `30s` to `15m` so normal browsing keeps the container warm. The cold-start retry budget grows to roughly 60 seconds (`[0, 2000, 4000, 8000, 16000, 30000]` ms backoff slots) to cover Cloudflare's container provisioning + the framework's boot-time SLH-DSA-SHAKE-256f audit-signing keypair generation. If every retry still hits a closed port, the visitor lands on a designed `Warming up — blamejs.shop` page that auto-refreshes every eight seconds, instead of the SDK's raw `Failed to start container` text. · *Container instance type → `standard-2`* — `wrangler.toml` sets `instance_type = "standard-2"`. The `standard-1` profile (0.5 vCPU) couldn't reliably finish the boot-time SLH-DSA-SHAKE-256f keypair generation inside the worker's retry budget on cold starts; `standard-2` reduces the cold-start window comfortably under the new 60-second budget. **Removed:** *Inline `<style>` block + Google Fonts dependency* — The framework's default `Content-Security-Policy: style-src 'self'` blocks both inline `<style>` blocks and external-host stylesheets. The 346-line layout style block + the `fonts.googleapis.com` + `fonts.gstatic.com` `<link>` tags are gone; the layout now uses a system font stack (`ui-sans-serif, system-ui, -apple-system, …`) so no third-party host needs to be allow-listed in CSP. The theme stylesheet is the single source of truth for typography. · *`style=""` attributes on every storefront renderer* — Twenty-six inline `style=""` attributes across the cart, checkout, pay, order, and account renderers were the other half of the inline-style CSP problem. They're replaced by semantic class hooks (`cart-line__update`, `cart-line__btn`, `form-stack`, `form-row`, `form-row--inline`, `form-field__input--xs`, `pay-card`, `pay-card__element`, `pay-card__submit`, `pay-card__message`, `checkout-summary`, etc.) defined in the theme stylesheet. **Fixed:** *License copy — three places said `MIT` when the framework ships under Apache 2.0* — The utility bar pill, the hero stats row, and the footer copyright line each labelled the project `MIT`. The actual `LICENSE` is Apache 2.0 and `package.json#license` has always been `Apache-2.0`. All three render the correct text now. · *Collection-tile gradients* — The six home-page collection tiles ship per-tile gradient art (orange, charcoal, purple, green, coral, navy) plus a corner-highlight overlay. A later `.collection-card__art` rule was clobbering the per-tile `--N` modifiers via `background-image` shorthand specificity, leaving every tile gray. Tiles now store the gradient as a `--card-gradient` custom property per modifier so both the highlight overlay and the base gradient compose correctly. · *Hover specificity on the on-dark secondary CTA* — The hero's `VIEW ON GITHUB` ghost button rendered with the default dark text (`a.btn-secondary` selector outranked `.btn-secondary--on-dark`) so the text was invisible on the dark hero. The on-dark modifier now uses `a.btn-secondary.btn-secondary--on-dark` to bump specificity, restoring the white text + translucent border. **Migration:** *Theme override path* — Operators replace the default look in one of three ways: (1) upload a replacement `main.css` to R2 at `themes/default/css/main.css` — same key, same `<link>` URL, no code change required; (2) pass `opts.theme_css` to any storefront renderer (`renderHome`, `renderProduct`, `renderCart`, …) to point a request at a different stylesheet URL; (3) register a named theme via the `theme` primitive and let the storefront's `assetUrl("css/main.css")` resolve to `/assets/themes/<name>/css/main.css`. The page-level `?v=<package-version>` cache-buster appends automatically when no explicit `theme_css` is supplied. · *Demo seed* — `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-products.sql` lands four demo products with one active USD price each + initial inventory. Idempotent. Drop the rows with `DELETE FROM products WHERE id LIKE '00000000-0000-7000-8000-%';` when replacing with a real catalog. · *Vendor refresh — blamejs `v0.11.44`* — `scripts/vendor-update.sh blamejs latest` refreshed the vendored framework to `v0.11.44`. Carries the framework's own storefront-test, security-header, csp middleware, and router refinements through to this deploy.

- v0.0.33 (2026-05-21) — **Subscriptions primitive — Stripe-backed recurring billing.** New `subscriptions` primitive composes the Stripe Subscriptions API for recurring billing. Operators model recurring offers as `subscription_plans` (mirroring a pre-created Stripe Price), bind customers to plans via `subscriptions.create`, and let the webhook handler replay `customer.subscription.*` events into the local row. The one-time-order PaymentIntent path is unchanged. **Added:** *Migration `0009_subscriptions.sql`* — Two new tables. `subscription_plans` mirrors a Stripe recurring Price: `stripe_price_id`, `interval` (`day|week|month|year`), `interval_count` (1..12), `currency`, `amount_minor`, `trial_days` (0..730), optional `variant_id` FK (NULL allowed — standalone plans without a catalog variant are valid). `subscriptions` mirrors Stripe's subscription object byte-for-byte: `stripe_subscription_id UNIQUE`, `status` matching Stripe's enum (`trialing|active|past_due|canceled|incomplete|incomplete_expired|unpaid`), `current_period_start/_end`, `cancel_at_period_end`. Indexed by variant, active, customer, status, and stripe id. · *`subscriptions` primitive* — `lib/subscriptions.js` ships `create({ query, payment })` returning `{ plans, subscriptions }`. `plans.{create, get, list, update, archive}` covers CRUD over the catalog-side recurring offer. `subscriptions.create({ customer_id, plan_id, payment_method_id })` POSTs to Stripe via the payment dep, then persists the returned object locally so the shop has an authoritative view without round-tripping to Stripe on every read. `subscriptions.cancel(id, { at_period_end })` routes at-period-end through Stripe's UPDATE (`cancel_at_period_end: true`) while immediate cancel uses DELETE. `subscriptions.byStripeId(stripeId)` is the webhook lookup. `subscriptions.handleStripeEvent(event)` dispatches `customer.subscription.created/updated/deleted` into the local row. · *`payment.subscriptions` extension* — `lib/payment.js` gains a `subscriptions` namespace alongside `createPaymentIntent` / `refund`: `create(input, idempotencyKey)` → `POST /v1/subscriptions`, `retrieve(id)` → `GET`, `update(id, input, idempotencyKey)` → `POST /v1/subscriptions/:id`, `cancel(id, { at_period_end }, idempotencyKey)` → `DELETE` or `POST` with `cancel_at_period_end`. Input validation refuses missing customer / empty items / negative trial period at the entry point so the Stripe round-trip never starts with bad arguments. Existing one-time-PaymentIntent API is unchanged. · *Checkout webhook routing* — `checkout.handleStripeEvent` recognises `customer.subscription.created/updated/deleted` and routes them to `deps.subscriptions.handleStripeEvent` (when wired). The one-time-order `payment_intent.*` + `charge.refunded` event map is unchanged — both paths coexist on the same webhook endpoint, gated by `event.type`. With no `subscriptions` dep wired, subscription events return `{ handled: false, reason: "no-subscriptions-handler" }`. · *Admin routes* — `POST /admin/subscription-plans` creates a plan. `GET /admin/subscription-plans?variant_id=&active=` lists with optional filters. `GET /admin/subscription-plans/:id`, `PATCH /admin/subscription-plans/:id`, `POST /admin/subscription-plans/:id/archive`. `GET /admin/subscriptions?customer_id=&status=` lists subscriptions filtered by customer or Stripe status. `GET /admin/subscriptions/:id`. `POST /admin/subscriptions/:id/cancel` accepts `{ at_period_end }`. Every mutating route writes the standard audit row alongside the existing admin write events. **Detectors:** *Layer 1 — `subscriptions`* — `test/layer-1-state/subscriptions.test.js` exercises plans CRUD (create with + without variant, get, list with variant/active filters, update, archive), subscriptions.create with a stubbed Stripe surface (asserts idempotency key + price + customer flow through), refusal when the plan is archived, cancel at-period-end vs immediate, byStripeId lookup, handleStripeEvent upsert (updated + created replay), handleStripeEvent delete, unknown event types, and checkout routing for `customer.subscription.*` events (with + without subscriptions dep wired). Validation covers every entry point — bad interval, interval_count out of [1,12], trial_days out of [0,730], non-lowercase currency, non-positive amount_minor, malformed variant_id, missing customer_id / plan_id / payment_method_id at subscriptions.create, malformed status filter on list, missing event on handleStripeEvent.

- v0.0.32 (2026-05-21) — **Theme primitive — file-backed templates with fallback resolution.** Operators ship a custom storefront look without forking `lib/storefront.js`. A theme is a directory under `themes/<name>/` holding `layout.html` plus per-page views (`home.html`, `product.html`, `cart.html`, `checkout.html`, `pay.html`, `order.html`, `notfound.html`). When set, the storefront dispatches every render through `b.template`; any view missing from the active theme falls back to the bundled `themes/default/` baseline. The inline-string templates stay in force when no theme is configured. **Added:** *`theme` primitive* — `lib/theme.js` ships `create({ themesDir, name, fallback? })` returning `{ render, exists, assetUrl, name, fallback }`. Each theme owns a `b.template` engine bound to its own directory; render walks the active engine first, falls back to the default engine when the file is missing, and throws `ThemeError` when both miss. Slug-shaped (`^[a-z0-9][a-z0-9-]{0,63}$`) view names and theme names defend against path-traversal via request-derived input. · *Bundled `themes/default/`* — Reference theme matching the prior inline look: `layout.html` (header + nav + `{% block body %}` insertion point), per-page views for home / product / cart / checkout / pay / order / notfound, and `assets/css/main.css` (lifted out of the inline `<style>` block). Operators copy the directory and edit in place to fork the visual identity. · *Storefront integration* — `storefront.mount(router, deps)` accepts `deps.theme`; when set, every route handler renders via the theme's file-backed views. The `renderHome` / `renderProduct` / `renderCart` / `renderCheckoutForm` / `renderPayPage` / `renderOrder` / `renderNotFound` functions all accept an optional `opts.theme` so callers outside the mount path get the same dispatch shape. Absence of `opts.theme` preserves the inline-string templates byte-for-byte — no breaking change for current deploys. · *`SHOP_THEME` env wiring* — `server.js` builds a theme via `bShop.theme.create({ themesDir: process.env.SHOP_THEMES_DIR || "./themes", name: process.env.SHOP_THEME })` whenever `SHOP_THEME` is set. `SHOP_THEME_FALLBACK` overrides the chained fallback name (default `default`). Theme assets ship via R2 at `/assets/themes/<name>/...`; upload once via `wrangler r2 object put` so the Worker's static pass-through serves them with the existing cache headers. **Detectors:** *Layer 1 — `theme`* — `test/layer-1-state/theme.test.js` builds a temp themes directory, exercises active-then-fallback resolution, verifies `assetUrl` shape + traversal refusal, asserts `exists` reports active-OR-fallback hits, and covers the validation matrix (missing themesDir, bad slug, missing active-theme dir, bad viewName at render time).

- v0.0.31 (2026-05-21) — **Inventory low-stock alerts.** Per-SKU low-stock thresholds with optional alert fan-out. When a SKU's available stock (`stock_on_hand - stock_held`) drops below its configured threshold, the framework writes an `inventory_alerts` row, emits a `b.log.warn` line, and (when an outbound-webhooks dep is wired) fans out an `inventory.low_stock` event. The hot-path decrement DO triggers the alert; admin routes set thresholds and list recent fires. **Added:** *`inventoryAlerts` primitive* — `lib/inventory-alerts.js` ships `create({ query, webhooks?, logger? })` returning `{ check, fire, checkAndFire, list }`. `check(sku)` is a pure SELECT that returns `{ alert, available, threshold }`. `fire(sku, available, threshold)` persists a row in `inventory_alerts`, calls `webhooks.send("inventory.low_stock", payload)` when the webhooks dep is wired, and always calls `logger.warn(...)`. `checkAndFire(sku)` is the common-case one-call wrapper. The logger defaults to `b.log` bound with `component: "inventory-alerts"` so emitted lines are filterable in the aggregator. Webhooks and logger sinks are drop-silent on throw — the persisted alert row is the source of truth, observability failures never roll it back. · *`catalog.inventory.setThreshold` + `checkLowStock`* — `catalog.inventory.setThreshold(sku, threshold)` writes the per-SKU threshold (non-negative integer or `null` to disable). `catalog.inventory.checkLowStock(sku)` returns the same `{ alert, available, threshold }` shape as the alerts primitive's `check`. The catalog factory accepts an `onStockChange(sku)` callback that fires after every `inventory.release` / `inventory.restock` so the alerts primitive can hook in without the catalog needing to know about it. · *Migration `0008_inventory_thresholds.sql`* — Adds `low_stock_threshold INTEGER` (nullable) to the existing `inventory` table via `ALTER TABLE`. Creates `inventory_alerts` (`id` TEXT PK uuid7, `sku` TEXT, `available_at_alert` INTEGER, `threshold` INTEGER, `fired_at` INTEGER) with composite indexes on `(sku, fired_at)` and `(fired_at)` for admin listing. The `sku` column is a loose FK — alert rows survive inventory deletion so the audit trail outlives the SKU. · *Worker InventoryLock decrement → alert callback* — After every decrement that succeeds, the DO reads `low_stock_threshold` alongside the stock columns. If post-decrement available is strictly below the threshold, the DO POSTs to a new `/_/low-stock-alert` Worker endpoint (gated by the same `D1_BRIDGE_SECRET` as the SQL bridge). The Worker forwards to the container, which runs the alerts primitive — writes the row, fans out webhooks, emits the log line. Fire-and-forget at every hop so alert delivery never gates the stock hold. · *Admin routes* — `PATCH /admin/inventory/:sku/threshold` (body `{ threshold }`) sets or clears the threshold; the mutation is audited under `shop_admin.inventory.set_threshold`. `GET /admin/inventory/alerts` returns the most recent alerts (defaults to the 100 newest by `fired_at` DESC); optional `?sku=` narrows to a single SKU's history, `?limit=` + `?offset=` page through older alerts. **Detectors:** *Layer 1 — `inventory-alerts`* — `test/layer-1-state/inventory-alerts.test.js` exercises setThreshold + checkLowStock round-trip (no-threshold default, above-threshold, below-threshold, cleared, unknown SKU), below-threshold fire writes the row + logger line + webhook send with the full payload, above-threshold returns null without touching any sink, threshold-null disables the alert path, the webhooks dep is genuinely optional (logger still fires), list pagination with limit + offset and SKU narrowing, validation refusals at every entry point (bad sku / threshold / available / limit / offset / mis-shaped deps), and the catalog `onStockChange` callback firing the alert path on `inventory.release` end-to-end.

- v0.0.30 (2026-05-21) — **Bulk catalog CSV import via admin.** Operators upload a CSV to bulk-create products + variants + prices + inventory in a single request. The admin API gains `POST /admin/catalog/import` (Content-Type: `text/csv`, optional `?dry_run=true`) — rows sharing a `product_slug` collapse to one parent product (first row wins for title / status / description), each row attaches a variant + price + inventory entry, and per-row errors are collected and returned alongside the success counts so the operator can re-upload with fixes. **Added:** *`catalogImport` primitive* — `lib/catalog-import.js` ships `create({ catalog })` returning `{ importCsv }`. Parses through `b.csv` (RFC 4180), content-safety-filters every cell through `b.guardCsv` strict profile (refuses formula-injection cells starting with `=` / `+` / `-` / `@`, bidi overrides, C0 control bytes, null bytes, dangerous-function denylist including `WEBSERVICE` / `HYPERLINK` / `IMPORTXML`), then validates the exact 10-column header before any row is processed. Numeric columns (`variant_weight_grams`, `price_amount_minor`, `inventory_qty`) accept only `/^\d+$/` — trailing garbage like `"100abc"` refuses outright rather than coercing through `parseInt`. Slug + SKU pass through the catalog primitive's existing validators. · *Admin route `POST /admin/catalog/import`* — Accepts `Content-Type: text/csv` body + `?dry_run=true|false` query param. Returns `{ created: { products, variants, prices, inventory_rows }, errors: [{ row_index, message }], rows, dry_run }`. The audit row is written to the `shop_admin.catalog.import` action with the per-run summary (row count, error count, created counts). Mounts only when `catalogImport` is wired into `admin.mount()` deps — operators opt in implicitly by passing the dep. · *`text/csv` body parser* — `server.js` extends the body parser's `text` sub-parser content-type list to include `text/csv` alongside `text/plain` and bumps the limit to 2 MiB so the importer's own 1 MiB cap binds first. Existing JSON / urlencoded / multipart routes are unaffected. **Security:** *Size caps default 1 MiB / 10000 rows* — Configurable per call via `opts.maxBytes` / `opts.maxRows`. Catches accidental wrong-file uploads (an OS image, a database dump) before any parse cycle is spent. Refuses with a `TypeError` rather than a row-error — the whole upload is rejected. · *Not atomic — partial failure leaves partial state* — D1 doesn't expose multi-row transactions cleanly across the HTTP bridge. Inserts run sequentially and rely on the catalog primitive's existing `slug` + `sku` UNIQUE constraints for idempotency. A row that errors midway leaves the rows before it persisted; the operator re-uploads the same CSV to fill in the gaps, and rows that already landed surface as duplicate-slug / duplicate-SKU row-errors on the retry. **Detectors:** *Layer 1 — `catalog-import`* — `test/layer-1-state/catalog-import.test.js` exercises the 3-product / 5-variant happy path against an in-memory SQLite loaded from `migrations-d1/0001_catalog.sql`, multi-variant-per-product de-dupe (first row's title / status / description win), `dry_run: true` returning the same counts without writing, header validation refusing wrong-column-order / short / empty CSV, per-row errors collected without aborting (bad slug / bad SKU / non-numeric cell), duplicate-slug-across-runs surfacing as a row-error without corrupting the first upload, `maxBytes` / `maxRows` refusing outright, and `b.guardCsv` refusing formula-injection cells (`=cmd|/c calc!A1`, `+SUM(...)`, `=WEBSERVICE(...)`).

- v0.0.28 (2026-05-21) — **Customer accounts primitive (passkey-only).** Shoppers register an account with an email + display name, enroll a WebAuthn passkey on their device, and sign in passwordlessly thereafter. The customer row stores the SHA3-512 namespaced hash of the address (never the address itself); the storefront route layer drives the registration + assertion ceremonies, persists per-credential rows with monotonic counter tracking, and seals the authenticated session in an AEAD-encrypted cookie keyed off the vault. **Added:** *Migration `0006_customers.sql`* — Two tables. `customers` carries `id` (TEXT PK uuid7), `email_hash` (TEXT, UNIQUE — `b.crypto.namespaceHash("customer-email", email)`, hex SHA3-512), `display_name` (TEXT, 128-char cap), `created_at`, `updated_at`. `customer_passkeys` carries `id` / `customer_id` (FK CASCADE) / `credential_id` (TEXT UNIQUE, base64url WebAuthn handle) / `public_key` (TEXT, COSE bytes base64) / `counter` (INTEGER, monotonic) / `transports` (comma list) / `created_at`. Indexed by `email_hash` for login lookups and `customer_id` for the per-account credential listing. · *`customers` primitive* — `lib/customers.js` ships `create({ query })` returning `{ register, get, byEmailHash, hashEmail, listPasskeys, addPasskey, getPasskeyByCredentialId, updatePasskeyCounter, removePasskey }`. Every email input runs through `b.guardEmail.validate` at the strict profile (refuses smuggling / header-injection / multi-@ / mixed-script) before the domain is case-folded and hashed; duplicate registrations refuse with `CUSTOMER_DUPLICATE`. `addPasskey` refuses a duplicate `credential_id` with `PASSKEY_DUPLICATE`; `updatePasskeyCounter` refuses a non-monotonic counter with `PASSKEY_COUNTER_REGRESSION` (the WebAuthn L3 clone-detection signal). Counter == 0 is the authenticator-doesn't-track marker and skips the write. · *Order pagination by customer* — `order.listForCustomer(customerId, { limit, cursor })` returns the customer's history ordered by `updated_at DESC, id DESC`. The cursor format matches `catalog.products.list` — HMAC-tagged via `b.pagination`, refuses orderKey-mismatched or tamper-bit-flipped values. Order rows arrive hydrated with `ship_to` + `lines` so the account dashboard avoids a per-row second trip. The HMAC secret is `opts.cursorSecret` (required in production, dev placeholder otherwise — same posture as `catalog`). · *Storefront account routes* — `GET /account/login` and `GET /account/register` render the email / display-name forms with an inline `<script>` that drives `navigator.credentials.get` / `.create` from the JSON challenge endpoints. `POST /account/passkey/register-begin` + `/register-finish` and `/login-begin` + `/login-finish` carry the WebAuthn options + verify the attestation / assertion via `b.auth.passkey.verifyRegistration` / `verifyAuthentication`. The login flow merges any anonymous cart into the now-customer-owned cart via `cart.setCustomer` before issuing the session. `GET /account` renders the customer name + the last 10 orders via `order.listForCustomer`. `POST /account/logout` clears the session cookie. · *Sealed authenticated-session cookie* — Cookie name `shop_auth`. The payload is an AEAD-encrypted envelope (`b.vault.seal` — XChaCha20-Poly1305 keyed off the deployment's vault KEK) holding `{ customer_id, exp }` with a 14-day TTL. Attributes are `HttpOnly; Secure; SameSite=Lax; Path=/`. The challenge round-trip uses a sibling `shop_auth_chal` cookie that's also vault-sealed, Path=/account scoped, and expires after 5 minutes. A rotated vault invalidates every outstanding authenticated session — operators get logout-everywhere without a separate revocation list. **Detectors:** *Layer 1 — `customers`* — `test/layer-1-state/customers.test.js` covers register + get round-trip, email-hash uniqueness, case-fold collision (same address with capital-letter domain refuses), strict-profile validation (multi-@ / header-injection refusal), passkey enroll + list + remove, duplicate-credential refusal across customers, and counter monotonic enforcement including the WebAuthn 0-counter no-op case. · *Layer 1 — `order.listForCustomer`* — `test/layer-1-state/order.test.js` gains a `_listForCustomer` cluster: rows arrive hydrated with `lines` + `ship_to`, ordering is `updated_at DESC` (a transitioned order surfaces above newer rows), pagination round-trips the HMAC-tagged cursor, a tampered cursor refuses, customer scoping excludes other customers' orders, and bad `limit` values refuse.

- v0.0.24 (2026-05-21) — **Design-system foundation for the storefront.** The default theme's layout shell is rebuilt around a published token system. Operators forking the storefront now compose against CSS custom properties for palette, typography, spacing, radius, shadow, and motion — page renderers no longer redeclare values inline. The header gains a search form and the layout ships a brand-tinted footer. **Changed:** *Storefront `LAYOUT` exposes a CSS token system* — `:root` now declares the palette (`--ink`, `--ink-2`, `--mute`, `--hair`, `--paper`, `--bg`, `--accent`, `--accent-d`, `--accent-l`, `--danger`), a typography scale (`--font-display`, `--font-body`, `--font-mono`, `--text-xs` through `--text-3xl`, `--lh-tight`/`--lh-snug`/`--lh-body`), a 4px-base spacing scale (`--space-0` through `--space-8`), radius tokens (`--radius-sm`/`--radius-md`/`--radius-lg`/`--radius-pill`), shadow tokens (`--shadow-sm`/`--shadow-md`/`--shadow-lg`), and motion tokens (`--ease-out`, `--duration-fast`, `--duration-mid`). Forks override at `:root` instead of patching individual rules. · *Header gains a search form* — The sticky header now renders a `GET /search` form alongside the brand and cart pill (`<input type="search" name="q">`). The form posts to a route the catalog primitive will own; until that route lands the search button returns a `Not found` page. Mobile breakpoint wraps the search input onto its own row. · *Site footer* — Layout ships a brand-tinted footer carrying the shop name, a one-line tagline ("server-rendered HTML, zero npm runtime dependencies, security defaults on"), a primitive list (Built on blamejs · Server-rendered · PQC-first), and a copyright with the current UTC year. The wrapper layout adds a flexbox column on `<body>` so the footer pins to the viewport bottom on short pages. · *Accessibility + motion defaults* — Layout reset adds a skip-link to `#main`, `:focus-visible` outlines (2px accent, 2px offset) on every interactive element, ARIA labels on the cart pill + primary nav, and wraps every transition behind `@media (prefers-reduced-motion: no-preference)`. Price + total cells render in `font-variant-numeric: tabular-nums` so column alignment holds across digit widths. · *Shared building-block classes* — The layout `<style>` block defines `.grid`, `.card`, `.btn`, `.btn-secondary`, `.summary-table`, `.empty`, `.price`, `.total`, `.cart-pill`, and `.hero` against the new tokens so per-page renderers (home / product / cart / checkout / pay / order) compose them instead of redeclaring colors + spacing. **Detectors:** *Layer 1 — `storefront._layoutTokens`* — `test/layer-1-state/storefront.test.js` gains an assertion cluster that pins every required CSS custom property (palette, typography scale, spacing scale, radius, shadow, motion), the reset + a11y hooks (`box-sizing`, `:focus-visible`, `prefers-reduced-motion`, `tabular-nums`, `id="main"`), the shared classes (`.grid`/`.card`/`.btn`/`.btn-secondary`/`.summary-table`/`.empty`/`.cart-pill`/`.hero`), the search form shape (`action="/search"`, `method="get"`, `name="q"`, `type="search"`), and the footer (primitive list + copyright).

- v0.0.23 (2026-05-21) — **Storefront purchase flow — full HTTP integration test.** The smoke gate now boots a real HTTP server, seeds a product, and walks the storefront purchase flow end-to-end (home → product detail → add-to-cart → cart view → line update → line remove). A failure anywhere in the storefront route mount, the body parser, the cart session cookie, or the cart/catalog SQL surfaces in CI instead of in a production rollout. **Added:** *Layer 2 integration suite* — `test/smoke.js` already walked `test/layer-2-integration/` when present; this release populates that directory with the first layer-2 test (`storefront-flow.test.js`). The smoke orchestrator picks it up automatically — no change to the smoke gate itself. · *`storefront-flow.test.js` — end-to-end HTTP purchase flow* — Boots `b.createApp` on an ephemeral 127.0.0.1 port with the storefront routes mounted exactly as `server.js` mounts them in pure-storefront mode. Catalog + cart wire against an in-memory `node:sqlite` database loaded from the live `0001_catalog.sql` + `0002_cart.sql` migrations so the test exercises the same SQL the live D1 sees. Vault, local DB at-rest, and audit-signing all opt into plaintext mode (the temp data dir is removed at teardown); the boot-time SNTP drift check is suppressed via `BLAMEJS_SKIP_NTP_CHECK=1` so the test never touches the network. The flow walks `GET /`, `GET /products/:slug`, `POST /cart/lines`, `GET /cart`, `POST /cart/lines/:id/update`, `GET /cart`, `POST /cart/lines/:id/remove`, `GET /cart` — asserting on HTTP status, redirect targets, body shape, the `shop_sid` session cookie, line totals, and the empty-cart state. The server is shut down via `app.shutdown()` so the port releases cleanly. · *HTTP integration helpers in `test/helpers/index.js`* — Two new helpers usable by every layer-2 test: `cookieJar()` returns a minimal in-memory jar that captures `Set-Cookie` headers and replays them as a single `Cookie` header on subsequent requests (parses `Max-Age=0` for explicit eviction; ignores `Secure` since loopback traffic is plaintext by design). `httpRequest({ port, path, method, headers, form, body, jar })` wraps `node:http.request` in a promise that resolves to `{ status, headers, body }` — `body` is a UTF-8 string. Form-encoded POSTs are the dominant storefront shape; pass `opts.form` as a plain object and the helper sets the content-type and serializes the body. Default headers (User-Agent, Accept-Language, Sec-Fetch-Mode) match a realistic browser shape so tests don't trip the framework's bot-guard heuristics in setups that keep it enabled.

- v0.0.22 (2026-05-21) — **Analytics primitive + admin dashboard.** Operator-facing read-only aggregates over the orders table: window summary (order counts + net revenue + per-status buckets), revenue-by-day, top SKUs by units sold, and recent orders. Surfaced as JSON under `/admin/analytics/*` and rendered as a single-page server-rendered HTML dashboard at `/admin/dashboard`. Revenue counts `cancelled` orders at zero and subtracts `refunded` orders so the headline number tracks what reached the bank. **Added:** *`analytics` primitive* — `lib/analytics.js` ships `create({ query })` returning `{ summary, revenueByDay, topSKUs, recentOrders }`. Each method takes an `{ since, until }` window (epoch milliseconds, defaults to last 30 days, refused above 1 year span). `summary` returns `{ currency, total_orders, total_revenue_minor, by_status }` per currency — a single object for single-currency windows, an array when multiple currencies are in scope. `revenueByDay` groups by `date(updated_at/1000, 'unixepoch')` so the bins are calendar-aligned UTC days. `topSKUs` joins `order_lines` to `orders` for the window filter and excludes cancelled + refunded orders from the units count. `recentOrders` returns the most-recent N orders for the activity feed. Revenue policy: `cancelled` excluded entirely, `refunded` subtracts, every other status adds at face value. · *Admin analytics routes* — `lib/admin.js` mounts `GET /admin/analytics/summary`, `GET /admin/analytics/revenue-by-day`, `GET /admin/analytics/top-skus`, `GET /admin/analytics/recent-orders` returning JSON, plus `GET /admin/dashboard` returning a server-rendered HTML page. Routes only register when `deps.analytics` is supplied — operators opt in by constructing the primitive and passing it into `admin.mount`. The dashboard is `noindex,nofollow` and bearer-token gated alongside the existing admin surface. Query-param validation refuses non-integer `since` / `until` / `limit` with a 400. · *Server-rendered HTML dashboard* — `admin.renderDashboard` lays out an eight-card stat grid (orders + net revenue + the six FSM status buckets), an inline SVG sparkline of revenue-by-day, a top-SKUs table, and a recent-orders activity panel with status pills. Matches the storefront brand palette (`#191919` ink, `#fa4f09` accent, Montserrat headlines, Inter body). Multi-currency operators see a secondary `Other currencies in window` block with per-currency totals. Zero client-side JavaScript — every value is rendered server-side and HTML-escaped through a strict `{{var}}` substitution that refuses unknown / unused placeholders. **Detectors:** *Layer 1 — `analytics`* — `test/layer-1-state/analytics.test.js` seeds rows across pending / paid / fulfilling / refunded / cancelled statuses + multiple UTC days + multiple currencies, then asserts: `summary` returns the per-status bucket counts with refunded subtracting and cancelled excluded from revenue; the empty-window case returns a zero-filled USD shape; the multi-currency window returns an alphabetically-sorted array. `revenueByDay` groups by UTC calendar day, sorts day ASC, applies the refund-subtracts / cancelled-excluded policy. `topSKUs` aggregates units across multiple orders, sorts by units_sold DESC, excludes cancelled order lines, honors LIMIT. `recentOrders` orders by created_at DESC with the right row shape. Validation covers non-integer `since` / `until`, `since >= until`, windows > 1 year, and limits outside `[1, 100]`. Admin-route smoke covers the bearer gate, JSON route shapes, HTML dashboard shape (DOCTYPE + SVG + status pill + escaped shop name), and the bad-param 400 path.

- v0.0.21 (2026-05-21) — **Outbound merchant webhooks.** Operators register HTTPS endpoints to receive order-state-change events. Each delivery is signed via `b.webhook.signer` using a per-endpoint secret (returned ONCE at create); receivers verify with the framework's standard `Webhook-Signature` header recipe. The order primitive accepts an optional `webhooks` dependency and fans every transition out to subscribed endpoints automatically. **Added:** *Migration `0005_webhooks.sql`* — Two tables: `webhook_endpoints` (id, url, secret, events allowlist or `*`, active flag, created_at/updated_at; index on active) and `webhook_deliveries` (id, endpoint_id FK with cascade, event_type, payload_json, attempts, last_status, last_error, delivered_at, created_at; composite index on endpoint_id+created_at). The secret is generated by the framework at create time and returned ONCE — operators record it for verification; subsequent reads never reveal it. · *`webhooks` primitive* — `lib/webhooks.js` ships `create({ query, transport?, retry? })` returning `{ endpoints: { create, get, list, update, delete }, deliveries: { list, retry }, send(eventType, payload), KNOWN_EVENTS }`. `send` queries active endpoints, filters by event subscription (`*` or comma-list), signs each delivery via `b.webhook.signer({ algo: "hmac-sha3-512", keys: { v1: <secret> } })`, persists the delivery row before the POST, and updates status (1xx-3xx = delivered, 4xx/5xx or transport error = failed with `last_error`). Transient transport errors (ECONNRESET / ECONNREFUSED / etc.) retry once via `b.retry.withRetry`. Idempotency key bound into the signed string is a fresh `b.uuid.v7()` per delivery so manual retries replay safely on the consumer side. · *URL + event validation* — `endpoints.create` refuses any non-HTTPS URL via `b.safeUrl.parse({ allowedProtocols: ["https:"] })` so an operator typo (`http://`) surfaces at admin-write time rather than leaking cleartext deliveries. `events` accepts the wildcard `*` or a comma-separated allowlist drawn from the recognized event types (`order.mark_paid`, `order.start_fulfillment`, `order.mark_shipped`, `order.mark_delivered`, `order.cancel`, `order.refund`); unknown event types are refused. · *Order transition fan-out* — `lib/order.js`'s `create({ query, webhooks? })` accepts an optional `webhooks` dependency. When present, every successful `transition()` call fires `webhooks.send("order." + event, { order, transition })` after the transition persists. Delivery failures never roll back the transition — the failure already lives in `webhook_deliveries.last_error` for operator review. Backward-compatible default of `null` so existing callers (and the test suite) keep working without a webhooks instance. · *Admin routes* — `lib/admin.js` mounts `POST /admin/webhooks` (returns the row including the one-time secret), `GET /admin/webhooks`, `PATCH /admin/webhooks/:id` (url / events / active), `DELETE /admin/webhooks/:id`, `GET /admin/webhooks/:id/deliveries?limit=`, and `POST /admin/webhooks/deliveries/:id/retry` for manual re-delivery. Every mutation writes to the existing `shop_admin` audit namespace. **Detectors:** *Layer 1 — `webhooks`* — `test/layer-1-state/webhooks.test.js` exercises endpoint CRUD, URL validation (refuses http://, ftp://, malformed), event-type allowlist enforcement, signing round-trip (verifies the emitted `Webhook-Signature` against `b.webhook.verifier`), event filtering (single-event vs wildcard subscriber + inactive endpoint skip), failure persistence (5xx leaves `last_error` set + no `delivered_at`), retry on transient transport error (single retry via `b.retry.withRetry`), manual retry endpoint (failure row flips to delivered + attempts bumped), and order-transition fan-out (a `mark_paid` transition fires a `order.mark_paid` delivery against the wired-in dispatcher).

- v0.0.20 (2026-05-21) — **Catalog product search — storefront + admin.** Customers and operators can now search the product catalog by free-text query. The storefront header gains a search box that routes to `GET /search`, and the admin API gains `GET /admin/products/search?q=` for draft + archived visibility. The matcher runs case-insensitive `LIKE` against `title` and `description`, escaping the `%` / `_` LIKE wildcards so a query like `100%` matches only the literal substring. **Added:** *`catalog.products.search({ q, status?, limit?, cursor? })`* — Lowercased substring match against `title` + `description` via SQLite `LIKE ... ESCAPE '\'`. The user-supplied `q` is trimmed, capped at 200 chars, and has `%` / `_` / `\` escaped before binding so wildcard characters in the query never widen the match. Results are sorted `updated_at DESC, id DESC` and paginated through the same HMAC-tagged tuple cursor that `products.list` uses — operators can hand-roll their own admin search UIs on top without re-implementing keyset pagination. Empty / whitespace-only `q` returns `{rows: [], next_cursor: null}` rather than throwing, so storefront and admin route handlers can render an empty-state page without a try/catch. · *Storefront search route + header form* — `GET /search?q=<query>` calls `catalog.products.search({ q, status: "active", limit: 24 })` and renders the new `renderSearch(opts)` page. The shared layout header gains a `<form action="/search" method="get">` input that pre-fills with the current query on the results page. The query is HTML-escaped through the strict template engine so a reflected `<script>` payload lands as escaped text inside both the summary line and the input's `value` attribute. · *Admin search route* — `GET /admin/products/search?q=&limit=&cursor=` returns the full `{rows, next_cursor}` page with no status filter — admins see draft + archived products alongside active ones so a mis-published slug is findable before promotion. The route is bearer-token gated under the existing `ADMIN_API_KEY` and slices `q` to 200 chars before handing to the primitive. **Detectors:** *Layer 1 — `catalog._search`* — `test/layer-1-state/catalog.test.js` covers: title match, description match, case-insensitivity in both directions, empty / whitespace-only query returning empty rows, LIKE-metacharacter escape (`100%` matches only the literal `100%` product, `_idget` returns zero rows), status filter behavior (no-status returns draft + archived), pagination round-trip with cursor distinctness, and TypeError shape for non-string / over-length / bad-status / bad-limit input. · *Layer 1 — `storefront._search`* — `test/layer-1-state/storefront.test.js` covers `renderSearch` output shape (header, summary, product grid, pre-filled input), singular vs plural summary, empty-state copy distinguishing blank `q` from `q with no matches`, and reflected-XSS escape on a `<script>`-bearing query.

- v0.0.19 (2026-05-21) — **Media upload admin route — container → Worker → R2.** Admin API gains `POST /admin/media/upload` so operators can attach product imagery without pre-uploading to R2 via the wrangler CLI. The container fetches the source URL through `b.httpClient` (SSRF gate, retry, TLS 1.3 minimum), validates the served content-type matches the operator's declared one, generates a UUIDv7 key under `media/`, pushes the bytes to a new Worker R2 bridge, then records the media row via `catalog.media.attach`. The Worker holds the R2 binding so the container never carries an R2 API token; the upload bridge is gated by the same shared secret the D1 bridge uses. **Added:** *`r2Bridge` — container → Worker upload adapter* — `lib/r2-bridge.js` ships `create({ bridgeUrl, bridgeSecret, bridgePath?, timeoutMs?, httpClient? })` returning `{ put(key, body, contentType) }`. POSTs to the Worker's `/_/r2/put` endpoint with `application/octet-stream` body, `x-d1-bridge-secret` auth header, `x-r2-key` + `x-r2-content-type` metadata headers. Composes on `b.httpClient.request` so every outbound call inherits the SSRF gate, retry policy, circuit breaker, ALPN HTTP/2 transport, and TLS 1.3 minimum. Returns `{ ok, key, size }` on success; surfaces Worker error envelopes + HTTP 4xx/5xx + timeouts as typed throws (`R2_HTTP_<status>`, `R2_TIMEOUT`, `R2_BAD_RESPONSE`). · *Worker `/_/r2/put` endpoint* — `worker/index.js` accepts POST on the configurable `R2_BRIDGE_PATH` (default `/_/r2/put`). Same constant-time `D1_BRIDGE_SECRET` gate as the SQL bridge. Re-validates the `x-r2-key` shape (no leading `/`, no `..` segments, ≤ 1024 chars) + the `x-r2-content-type` matches the RFC 7231 `type/subtype` grammar (≤ 255 chars) so a compromised secret can't write arbitrary paths or smuggle control bytes into the bucket's object metadata. Writes via `env.ASSETS.put(key, body, { httpMetadata: { contentType } })`. Returns `{ ok, key, size }` JSON; refuses empty bodies + missing R2 binding with descriptive 4xx/503 envelopes. · *`POST /admin/media/upload` admin route* — `lib/admin.js` mounts the upload route when the operator wires an `r2_bridge` instance. Body shape: `{ product_id?, variant_id?, content_type, source_url, alt_text?, position?, width?, height? }`. Fetches `source_url` via `b.httpClient.request` (SSRF gate refuses cloud-metadata + RFC 1918 + loopback destinations), refuses content-type mismatches between the declared MIME and the served MIME with 422, generates a UUIDv7 key under `media/<id>.<ext>` (extension inferred from the declared content-type), uploads via the bridge, then calls `catalog.media.attach` to record the row. Returns the media row plus `asset_url` (the public `/assets/...` URL operators preview in the admin UI). The route is omitted entirely when no bridge is wired — operator opts in by setting `D1_BRIDGE_URL` + `D1_BRIDGE_SECRET`. · *`server.js` wires the bridge into the admin mount* — The application boot constructs an `r2Bridge` instance when `D1_BRIDGE_URL` + `D1_BRIDGE_SECRET` are present (the same env pair that wires the D1 backend), then hands it to `admin.mount` as `deps.r2_bridge`. The Worker passes `R2_BRIDGE_PATH` through to the container env alongside `D1_BRIDGE_PATH` so an operator can move the upload endpoint without touching the container code. **Detectors:** *Layer 1 — `r2-bridge`* — `test/layer-1-state/r2-bridge.test.js` exercises factory validation (every required + optional opt), put() happy path (URL+path concat, method, every header, octet-stream content-type, Buffer body), custom bridgePath, Worker error envelope as typed throw, HTTP 5xx with message surfaced, malformed-JSON response as `R2_BAD_RESPONSE`, AbortError → `R2_TIMEOUT`, network error preserves the original code, put() argument validation (empty key, absolute path, parent-segment escape, missing body, empty content-type, malformed MIME), and string + Uint8Array body encoding to the wire Buffer.

- v0.0.18 (2026-05-21) — **Storefront tax + shipping read from `shop_config`.** The storefront mount stops hardcoding empty tax rules + a single $0 standard shipping service and instead reads `tax.rules`, `shipping.services`, and `shipping.default_id` from the `config` primitive on every checkout. Operators set these via `PUT /admin/config/:key` and the next checkout picks up the new values (modulo the config primitive's 30s read cache); zero-rate tax + a `std` $0 shipping zone remain the documented defaults when no row is present. **Changed:** *`server.js` wires config-backed tax + shipping into the storefront checkout* — The storefront mount now constructs a single `config = bShop.config.create({})` instance at boot and hands the storefront a `tax` adapter + `shipping` adapter whose `calculate(ctx)` / `rates(ctx)` methods rebuild a fresh `bShop.tax.create({ rules })` / `bShop.shipping.create({ services })` from the latest config rows on each call. `selected_shipping_id` likewise resolves from `shipping.default_id` per request via an async resolver passed as `deps.default_shipping_id`. The admin mount shares the same config instance so writes invalidate the cache the storefront reads. · *`storefront.checkout` accepts an async `default_shipping_id` resolver* — `POST /checkout` now `await`s `deps.default_shipping_id()` when the dep is a function, falling back to treating it as a literal string otherwise. Existing operators passing a string keep working; the new shape lets the resolver re-read `shipping.default_id` from config without a container restart. **Detectors:** *Layer 1 — `storefront-config-wiring`* — `test/layer-1-state/storefront-config-wiring.test.js` exercises the config-backed wrappers against an in-memory `shop_config` table: seeds `tax.rules` + `shipping.services` + `shipping.default_id`, asserts the wrapped adapters return the operator-configured values, and confirms zero-rate defaults kick in when the config table is empty.

- v0.0.17 (2026-05-20) — **Shop config primitive + admin routes.** Operator-tunable runtime configuration keyed by name with JSON-shaped values. Backed by a new `shop_config` table in D1. Admin API gains `GET/PUT/DELETE /admin/config/:key` + `GET /admin/config?prefix=` so operators set tax rules, shipping services, brand name, support email, and any other runtime tunable without re-deploying. **Added:** *Migration `0004_shop_config.sql`* — Single key/value table: `key` (TEXT PRIMARY KEY, matches `^[a-z][a-z0-9._]{0,63}$`), `value_json` (TEXT, ≤ 64 KiB after JSON.stringify), `version` (INTEGER, bumped on every UPSERT), `updated_at` (epoch ms). Indexed by `updated_at` for change-feed semantics later. · *`config` primitive* — `lib/config.js` ships `create({ query })` returning `{ get, getFresh, put, delete, list }`. Bounded 30s in-memory cache on reads; writes + deletes invalidate the entry. Every value round-trips through `JSON.parse` at write so a corrupted blob surfaces at admin-write time rather than at customer-checkout. `list(prefix)` filters by key prefix and sorts by key for stable admin paging. · *Admin routes* — `GET /admin/config?prefix=`, `GET /admin/config/:key`, `PUT /admin/config/:key` (body: `{ value }`), `DELETE /admin/config/:key`. The PUT mutation writes a row to the `shop_admin.config.put` audit namespace alongside the existing admin write events. **Detectors:** *Layer 1 — `config`* — `test/layer-1-state/config.test.js` exercises put+get round-trip for primitives / arrays / nested objects, default-value fallback on miss, version bump on second put, prefix-filtered list with stable sort, delete + idempotent re-delete, cache invalidation on put/delete (cache returns NEW value), and corrupted-at-rest row surfacing as a typed error. Validation covers bad key shape, value > 64KiB, undefined value, and non-string prefix.

- v0.0.16 (2026-05-20) — **`pricing.format` composes upstream `b.money`.** The currency-rendering path on every storefront / email / order page now flows through the framework's Money class (`b.money.fromMinorUnits(bigint, currency).format(locale)`) instead of constructing `Intl.NumberFormat` inline. Output is byte-identical for every supported currency (USD / EUR / GBP / JPY zero-decimal all match prior renders); the win is framework-policy alignment — Money owns ISO 4217 currency exponents and locale-correct fraction-digit selection across the entire blamejs ecosystem. **Changed:** *`pricing.format` swaps inline `Intl.NumberFormat` for `b.money`* — `lib/pricing.js` now coerces the `Number` minor-unit at the boundary into a `BigInt`, hands it to `b.money.fromMinorUnits(bigint, currency)`, and calls `.format(locale)`. The upstream Money primitive carries the ISO 4217 catalog (code → exponent) so JPY (exp=0), KRW (exp=0), and JOD (exp=3) all render correctly without us re-implementing the table. The pricing primitive's external API (`Number` minor units) is unchanged — every existing consumer (cart, checkout, order, storefront, email) keeps its current call shape.

- v0.0.15 (2026-05-20) — **README refresh — full primitive surface documented.** The README's `What ships` section now reflects every primitive landed since the foundation patch: catalog, cart, pricing, tax, shipping, payment, order, checkout, email, storefront, admin. Includes a `Operator quick-start` block walking through `wrangler login` → resource provisioning → secret setting → migration apply → deploy → seed-a-product. **Changed:** *README documents every primitive* — The `What ships` section is now split into Platform / Commerce primitives / Migrations applied to D1 / Tests subsections. Each primitive entry lists its module path, its function, and the composed blamejs surface it relies on. The Operator quick-start block carries the canonical wrangler one-time setup + deploy + seed-a-product command sequence so an operator landing on the repo has an unambiguous path from clone to live shop.

- v0.0.14 (2026-05-20) — **Worker container cold-start retry — 3 attempts, exponential backoff.** Container cold-start can still surface `Failed to start container: The container is not running` when the underlying SDK's default 8-second port-wait window is exceeded under provisioning load. The single-retry-with-1.5s-backoff shipped in v0.0.9 wasn't always enough; this patch ups it to three retries with 2s / 4s / 8s backoff (~14 seconds total patience for the cold-start window). **Changed:** *Vendor refresh blamejs v0.11.36 → v0.11.37* — Caught up to upstream. **Fixed:** *`worker/index.js` cold-start retry — 3 attempts* — `_forwardToContainer` now retries up to three times on `Failed to start container` / `no Container instance` 5xx responses with 2s / 4s / 8s exponential backoff. The first attempt is immediate; legitimate 5xx (non-cold-start body shapes) short-circuit so application errors still surface immediately. POST bodies buffer once on entry and re-serve from the ArrayBuffer on each retry; GET / HEAD have no body and clone cheaply.

- v0.0.13 (2026-05-20) — **Cart line mutations — update qty + remove.** Cart page becomes interactive: every line shows an inline qty input + Update button and a Remove button. POST routes wire to `cart.updateLine` and `cart.removeLine`. A Checkout call-to-action button anchors the cart summary so customers can advance through the flow without typing a URL. **Added:** *Editable cart lines* — `lib/storefront.js` now ships `CART_LINE_EDITABLE` — replaces the read-only line template with one that includes an inline `<form method="post" action="/cart/lines/:id/update">` carrying the qty input and a separate `<form method="post" action="/cart/lines/:id/remove">` for line removal. HTML forms only support GET/POST so the verb lives in the path (no `_method` override hack). · *POST /cart/lines/:id/update + /remove* — Two new route handlers in `storefront.mount`. Update validates qty (1..99) + calls `cart.updateLine(lineId, { qty })`; returns 404 when the line is unknown. Remove calls `cart.removeLine(lineId)`. Both 303 redirect to `/cart` on success so a refresh of the result doesn't replay the mutation. Errors from the cart primitive map to 400 (TypeError) or 500 (anything else). · *Checkout CTA on the cart page* — Cart summary now anchors a `<a class="btn" href="/checkout">Checkout →</a>` button so customers can progress without typing the URL. The button inherits the accent color from the default theme — same affordance as the in-page Update/Add-to-Cart buttons. · *Vendor refresh blamejs v0.11.35 → v0.11.36* — Caught up to upstream.

- v0.0.12 (2026-05-20) — **End-to-end checkout flow — shipping form, Stripe Elements, order confirmation.** Storefront completes the cart → checkout → pay → confirmation loop. The shipping form collects email + name + ISO 3166 country/state/postal, hands the values to `checkout.confirm`, persists the order in `pending`, and redirects to a Stripe Elements payment page with the per-order `client_secret`. After payment, Stripe redirects back to a server-rendered order confirmation page that shows status + lines + totals. **Added:** *Checkout flow routes* — `lib/storefront.js` mounts `GET /checkout` (shipping form pre-filled with cart subtotal), `POST /checkout` (calls `checkout.confirm` with idempotency key + redirects to `/pay/:order_id` with a short-lived `shop_pay` cookie scoped Path=/pay/ + SameSite=Strict carrying the `client_secret`), `GET /pay/:order_id` (renders Stripe Elements payment page — loads Stripe.js from `js.stripe.com/v3/`, mounts the Payment Element, redirects to the order page on success via Stripe's `return_url`), and `GET /orders/:order_id` (server-rendered confirmation showing status, line items, and the subtotal/tax/shipping/total breakdown). · *Wired through `server.js` when Stripe is configured* — The storefront mount picks up `checkout` / `payment` / `order` / `tax` / `shipping` / `default_shipping_id` / `stripe_publishable_key` deps when `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET` are set. The default-fallback tax + shipping are zero-rate / zero-cost (operator-configured proper rules in `wrangler.toml` `[vars]` or a config primitive land later). Without Stripe configured, the storefront stays browseable (home / product / cart) but the checkout routes don't mount. · *Worker env passthrough* — `worker/index.js` forwards `STRIPE_PUBLISHABLE_KEY` into the container env alongside the existing `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET` + `ADMIN_API_KEY` set. **Detectors:** *Layer 1 — storefront checkout renderers* — `test/layer-1-state/storefront.test.js` gains `_checkoutForm` (form action + field shape + subtotal rendering), `_payPage` (Stripe.js loaded + pk + client_secret injected as JSON literals + total rendered + order id surfaced), and `_orderPage` (status + line + tax + total surfaced) tests. Six new assertion clusters.

- v0.0.11 (2026-05-20) — **Admin API — bearer-token-gated CRUD.** Single-bearer-token admin surface composed on `b.crypto.timingSafeEqual`, `b.problemDetails`, and `b.audit`. Operators set `ADMIN_API_KEY` as a Worker secret and use it as the bearer for every `/admin/*` route. Coverage: products + variants + prices + inventory + media CRUD, order get + transition, Stripe-backed refund flow that fires the order FSM refund transition automatically. Multi-admin passkey-enrolled surface lands alongside the admin browser UI in v1.x. **Added:** *`admin` — bearer-token admin API* — `lib/admin.js` exposes `mount(router, deps)` that wires `/admin/*` routes behind a `Bearer` token check. Token comparison uses `b.crypto.timingSafeEqual` so a side-channel timing attack can't recover the value byte-by-byte. Length-mismatch short-circuits before the constant-time compare (the compare requires equal lengths). Every mutation writes an audit row to namespace `shop_admin` via `b.audit.emit`; failures are RFC 9457 problem documents via `b.problemDetails.send`. TypeError from a primitive surfaces as 400; any other thrown error as 500. · *Catalog + order + refund coverage* — Routes shipped in v1: `POST/GET/PATCH /admin/products`, `POST /admin/products/:id/archive|restore`, `POST/PATCH/DELETE /admin/variants`, `POST/GET /admin/variants/:id/prices`, `POST /admin/inventory` + `POST /admin/inventory/:sku/restock`, `POST/DELETE /admin/media`, `GET /admin/orders/:id`, `POST /admin/orders/:id/transition`, `POST /admin/orders/:id/refund` (when payment + Stripe API key are wired — the refund call goes through `payment.refund` with a stable idempotency key derived from the order id, then fires `order.transition(id, "refund", ...)` so the FSM advances atomically with the Stripe side). · *Wired into `server.js`* — Admin routes mount only when `ADMIN_API_KEY` is present in env — operators opt in by setting the secret (`wrangler secret put ADMIN_API_KEY`). The Worker passes the secret through to the container at start. Stripe-backed refund routes additionally require `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET`. **Detectors:** *Layer 1 — `admin`* — `test/layer-1-state/admin.test.js` exercises the bearer gate (missing → 401, wrong-same-length → 401, wrong-different-length → 401 short-circuit, right → 200, lowercase `bearer` accepted), product create + update + archive round-trip, order transition round-trip + illegal-transition surface as 4xx, and factory validation. Uses an in-memory fake router that supports `:param` route registration so the path matching matches what the real `b.router` does.

- v0.0.10 (2026-05-20) — **Storefront visual upgrade — brand identity + theme palette.** The default storefront theme adopts the framework's brand identity: the `blamejs.shop` logo served from R2, a monochrome-plus-accent palette (`#191919` / `#fa4f09` / `#ffffff`) sourced from the reference ecommerce templates shipped under `.template/`, Montserrat headlines and Inter body text. The home page gains a hero section that frames the framework's identity. The CSS is still inline; the theme primitive that lifts these into a swappable directory ships in v1.x. **Added:** *Brand identity in the default theme* — `lib/storefront.js`'s LAYOUT template now references `/assets/brand/logo.png` (served from the R2 bucket the Worker is bound to) and renders the wordmark next to the logo image in the site header. The logo PNG is uploaded once via `npx wrangler r2 object put blamejs-shop-assets/brand/logo.png --file=.template/blamejs.shop_logo.png --content-type=image/png --remote`; the source asset stays local-only under `.template/` (gitignored). · *Palette + typography from the reference templates* — The default theme palette derives from the `odor-buyer-file` reference template under `.template/`: `--ink: #191919`, `--ink-2: #414141`, `--mute: #727272`, `--hair: #d9d9d9`, `--accent: #fa4f09`, `--accent-d: #d8410a`, `--paper: #ffffff`, `--bg: #fafafa`. Headlines render in Montserrat (700), body text in Inter (400-600), both from Google Fonts with `preconnect` hints. Buttons, links, and the cart pill use the accent color for interaction state. · *Hero on the home page* — `renderHome` now prepends a hero section above the product grid — communicates the framework identity ("An open-source shop, built on blamejs") to first-time visitors and gives the page presence when the catalog is empty. The hero is part of the default theme and ships in the same LAYOUT; future themes can override via the theme primitive in v1.x. · *Card + table + form refinements* — Product cards gain hover state (subtle lift + shadow), prices render in the accent color, links show a bottom border that flips to the accent on hover. Table headers adopt all-caps Montserrat 600 with a 2px ink underline, total rows render with a thick ink top border. Cart summary moves into a `.summary-table` block with `--bg` fill. Forms inherit consistent input + button styling.

- v0.0.9 (2026-05-20) — **Cart-write routes + container cold-start retry.** Storefront gains the `POST /cart/lines` route plus a session-id cookie binding so the "Add to cart" form on the product detail page actually works end-to-end. Worker forwards now retry once on the cold-start "Failed to start container" 5xx so a single cold provisioning doesn't bubble to the shopper. Body parser middleware mounted in the application boot for form-encoded + JSON request bodies. **Added:** *Cart-write route — `POST /cart/lines`* — `lib/storefront.js` mounts the cart-add route alongside the read-only HTML pages. The handler resolves the cart via a `shop_sid` HttpOnly cookie (UUIDv7 session id, SameSite=Lax — blocks cross-site form POSTs from carrying the session, which is the load-bearing CSRF defense for cart-add). When the cookie is absent, the handler mints a fresh session id, sets the cookie, and creates the cart. On success the response is `303 See Other` to `/cart` so a refresh of the result page doesn't re-submit the form. · *Cart count on every storefront page* — `renderHome` / `renderProduct` / `renderCart` accept a `cart_count` opt; the route handlers compute it by looking up the session's active cart and reading `cart.listLines(c.id).length`. The header badge (`Cart (N)`) reflects the actual session state without a separate JSON ping. · *Body parser middleware mounted at boot* — `server.js` now wires `b.middleware.bodyParser()` so every route receives `req.body` parsed from `application/x-www-form-urlencoded` (the storefront's cart forms) and `application/json` (future admin API). Mounted before any route handler so the surface is uniform. · *Container cold-start retry in the Worker* — `worker/index.js`'s `_forwardToContainer` now buffers the request body and retries once on the `@cloudflare/containers` 5xx "Failed to start container" / "There is no Container instance" responses with a 1.5s backoff. A single cold-start race during deploy rollout or instance recycling no longer surfaces to the shopper as a 500. GET / HEAD short-circuit the body buffer; POST bodies are read into an ArrayBuffer once and re-served on the retry.

- v0.0.8 (2026-05-20) — **Storefront — server-rendered HTML for end customers.** Minimum viable storefront: read-only HTML for home (product list), product detail, and cart. Renderers are pure functions composable in isolation; `mount(router, deps)` wires them into a `b.router` instance. Inline string templates with the strict `{{var}}` renderer the email primitive uses — HTML-escaped substitution, refusal of unknown / unused placeholders. The full theme primitive (file-backed templates via `b.template`, asset fingerprinting, theme inheritance, customer-buildable themes) lands in v1.x. **Added:** *`storefront` — server-rendered HTML* — `lib/storefront.js` ships pure-function renderers (`renderHome`, `renderProduct`, `renderCart`, `renderNotFound`) plus a `mount(router, deps)` that registers the read-only GET routes against a `b.router` instance. The home page lists active products with their starting USD price; the product page shows title, description, and a variant table with SKUs + per-variant prices; the cart page renders lines + subtotal + grand total. Every substituted value is HTML-escaped via the strict renderer — XSS attempts through a product title surface as `&lt;script&gt;` in the rendered markup, never as executable HTML. · *Wired into `server.js`* — When the data layer is configured (`D1_BRIDGE_URL` + `D1_BRIDGE_SECRET` present), the container boot mounts `bShop.storefront.mount(router, { catalog, cart })` so `GET /`, `GET /products/:slug`, and `GET /cart` serve HTML directly. Without the data layer, the container falls back to the JSON identity ping (useful for local smoke without wrangler dev). **Detectors:** *Layer 1 — `storefront`* — `test/layer-1-state/storefront.test.js` exercises each renderer: home with products + empty state + cart count, product detail with variants + missing-variants empty state, cart with lines + subtotal + grand total + empty state. XSS escape verified by injecting `<script>` into a product title and asserting it's rendered as `&lt;script&gt;`. Validation tests at every entry point.

- v0.0.7 (2026-05-20) — **Transactional email primitive.** Transactional templates (order receipt, ship notification, refund confirmation) composed on `b.mail` with a strict `{{var}}` renderer that HTML-escapes every substituted value and refuses unknown / unused placeholders at composition time. Operators inject a `b.mail.create(...)` mailer at boot — transports include console / memory / smtp / http / resend out of the box. **Added:** *`email` — transactional templates* — `lib/email.js` exposes `create({ mailer, from })` returning `{ orderReceipt, shipNotification, refundConfirmation }`. Each method takes the domain object (order + customer plus a per-message extra like `tracking` or `amount_minor`), renders an HTML + text body via the strict templater, and hands the message to the injected mailer. Address shape is validated against `b.guardEmail.validate` (strict profile) before sanitization — `sanitize()` strips unicode / BIDI / control bytes but does not reject malformed shapes, so the validate-first-sanitize-after order is load-bearing. · *Strict `{{var}}` renderer* — `email._render(template, vars)` substitutes `{{name}}` placeholders, HTML-escapes every value (XSS defense), and throws if the template references an unknown variable OR if any provided variable wasn't referenced. Catches both the typo-in-template bug and the rename-without-updating-caller bug at composition time, before a customer-facing email ships with `{{customer_name}}` literal text or a missing field. · *Pluggable transport via `b.mail.transports`* — blamejs ships `b.mail.transports.{console, memory, smtp, http, resend}` — operators choose at app boot. `memory` records every send in an array (used by the layer-1 test to assert on message shape without a real SMTP server). The `email.create` factory takes the mailer; no transport coupling in the shop module itself. **Detectors:** *Layer 1 — `email`* — `test/layer-1-state/email.test.js` exercises every template against a fake mailer: order receipt with totals (subtotal, tax with jurisdiction, shipping, grand total), ship notification with carrier + tracking, refund confirmation with formatted amount. Covers HTML escaping (XSS attempt via `customer.name`), strict-renderer refusals (unknown placeholder + unused variable), and address-shape validation.

- v0.0.6 (2026-05-20) — **Checkout orchestrator + webhook upgrade to upstream b.webhook.** Checkout primitive that ties cart + catalog + pricing + tax + shipping + payment + order into a two-call API (`quote` + `confirm`) plus a webhook dispatcher that maps Stripe events to FSM transitions. Payment verifier swapped from a local node:crypto fallback to the upstream `b.webhook.verify` primitive (alg `hmac-sha256-stripe`, available since blamejs v0.11.25) — the local `node:crypto` allow marker is gone. **Added:** *`checkout` — orchestrator* — `lib/checkout.js` ships `create({ catalog, cart, pricing, tax, shipping, payment, order })` returning `{ quote, confirm, handleStripeEvent, STRIPE_EVENT_MAP }`. `quote({ cart_id, ship_to, selected_shipping_id })` is read-only — composes the priced quote (lines + totals + shipping rates + tax jurisdiction). `confirm({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key })` creates the Stripe PaymentIntent, persists the order in `pending`, marks the cart converted, returns `{ order, payment_intent: { id, client_secret, status } }`. `handleStripeEvent({ headers, rawBody })` verifies the signature via `payment.verifyWebhook`, looks up the order by `payment_intent_id`, and fires the appropriate FSM transition (`payment_intent.succeeded` → `mark_paid`, `payment_intent.canceled` → `cancel`, `charge.refunded` → `refund`). Re-delivered events are idempotent — if the order is already at or past the target state, the handler returns `{ handled: true, skipped: "already-advanced" }`. · *Payment verifier composes upstream `b.webhook.verify`* — `payment.verifyWebhook` now wraps `b.webhook.verify({ alg: "hmac-sha256-stripe", ... })` instead of reaching for `node:crypto.createHmac("sha256", ...)` locally. The upstream primitive ships full Stripe-spec verification (constant-time compare, multi-v1= rotation support, timestamp tolerance, optional `nonceStore` for replay defense) — composing it shrinks `lib/payment.js`, drops the `allow:non-shop-require` marker, and brings the verifier into framework-policy alignment. The `verifyWebhook` shape stays `{ ok, reason }` so checkout's handler doesn't need to change. **Detectors:** *Layer 1 — `checkout`* — `test/layer-1-state/checkout.test.js` stitches every prior primitive (catalog, cart, pricing, tax, shipping, payment, order) into an end-to-end flow against in-memory SQLite. Coverage: quote returns lines + totals + shipping rates + tax jurisdiction, confirm creates a PaymentIntent + pending order + converts the cart, confirm refuses zero-total carts, handleStripeEvent maps `payment_intent.succeeded` → `mark_paid`, re-delivered events are idempotent (`skipped: "already-advanced"`), bad signature throws `WEBHOOK_INVALID`. Payment outbound is stubbed (the orchestration is what we're testing); the real Stripe-API integration is exercised by `payment.test.js`. **References:** [blamejs v0.11.25 b.webhook.verify](https://github.com/blamejs/blamejs/blob/v0.11.25/lib/webhook.js)

- v0.0.5 (2026-05-20) — **Order primitive — FSM-driven post-checkout record.** The order primitive composes the freshly-landed `b.fsm` upstream surface to drive the post-checkout lifecycle. Orders persist in D1 with a versioned transition log; every state change goes through the FSM so illegal transitions throw at the framework boundary instead of corrupting the order record. **Added:** *`order` — FSM-driven post-checkout record* — `lib/order.js` ships `create({ query })` returning `{ createFromCart, get, byPaymentIntent, transition, setPaymentIntent, TERMINAL_STATES }`. States: pending → paid → fulfilling → shipped → delivered, plus refunded / cancelled (terminal). Events: mark_paid, start_fulfillment, mark_shipped, mark_delivered, cancel, refund. Each transition fires through `b.fsm` (which refuses illegal events at the framework boundary) and appends a row to `order_transitions` so the lifecycle is auditable end-to-end. `byPaymentIntent(piId)` is the lookup webhook handlers use to map Stripe events back to the local order. · *Migration `0003_order.sql`* — Three tables: `orders` (row per checkout, captures cart snapshot + totals + ship_to JSON + payment_intent_id with a unique index), `order_lines` (snapshot of cart_lines at order creation — immutable post-checkout), `order_transitions` (one row per FSM transition, including the initial `__init__ → pending` create-event). FK cascade on order delete removes lines + transitions. · *Vendor refresh blamejs v0.11.24 → v0.11.25* — Upstream v0.11.25 ships `b.fsm` (auditable in-process FSM with guards + onEnter/onExit + concurrent-transition serialization + toJSON/restore round-trip) and `b.money` (decimal-safe money with currency-aware arithmetic and conversion). Order composes `b.fsm`. Pricing will adopt `b.money` in a follow-up patch — the current `Intl.NumberFormat`-based formatter stays for now since it covers the storefront rendering use case. **Detectors:** *Layer 1 — `order`* — `test/layer-1-state/order.test.js` exercises createFromCart (persist + lines + init transition), the happy path (pending → paid → fulfilling → shipped → delivered with metadata captured on each transition), illegal transition refusal (can't ship before paying, can't deliver from pending, unknown events refused), cancel + refund branches, setPaymentIntent's pending-only constraint + byPaymentIntent lookup, and every validation entry point.

- v0.0.4 (2026-05-20) — **Payment primitive — Stripe adapter.** Stripe inbound webhook signature verification and outbound API calls (PaymentIntent create / retrieve / confirm / cancel + Refund) composed on `b.httpClient` and `b.crypto.timingSafeEqual`. No `stripe` npm dependency — every byte is either a Node built-in or a vendored blamejs primitive. The pluggable adapter contract lets future Adyen / Mollie / Paddle adapters land without changing caller code. **Added:** *`payment.stripe` — Stripe adapter* — `lib/payment.js` exposes `payment.create({ apiKey, webhookSecret })` returning `{ name, verifyWebhook, createPaymentIntent, retrievePaymentIntent, confirmPaymentIntent, cancelPaymentIntent, refund }`. Every outbound call accepts an explicit `idempotencyKey` so the caller (checkout / refund worker) controls retry safety. `verifyWebhook(headers, rawBody)` parses the `stripe-signature` header (multiple `v1=` entries supported for secret rotation), enforces a ±5-minute timestamp tolerance, runs constant-time signature compare via `b.crypto.timingSafeEqual`, and returns `{ ok, event }` on success or `{ ok: false, reason }` on rejection (`no-signature`, `timestamp-outside-tolerance`, `signature-mismatch`, `bad-json`). The body MUST be the raw request string — passing a parsed object throws. · *Outbound API via `b.httpClient`* — Stripe calls flow through `b.httpClient.request()` so the SSRF gate, retry policy, circuit breaker, ALPN HTTP/2 transport, and TLS 1.3 minimum all apply by default. Bodies are form-encoded with Stripe's bracket-notation for nested params (`metadata[order_id]=ord_1`, `automatic_payment_methods[enabled]=true`). The 15-second per-request timeout is operator-tunable via `payment.create({ timeoutMs })`. · *Pluggable adapter contract* — `payment.create({ adapter: "stripe", ... })` is the v1 surface. Unknown adapter names throw at construction time so a typo (`paddle` / `mollie`) surfaces before any payment flow runs. Future adapters expose the same `{ verifyWebhook, createPaymentIntent, ... }` shape — callers (checkout / order) bind to the contract, not the provider. **Detectors:** *Layer 1 — `payment`* — `test/layer-1-state/payment.test.js` exercises the webhook verifier (valid signature, case-insensitive header, multiple v1= entries for secret rotation, signature mismatch, timestamp-outside-tolerance, mutated body, missing timestamp, malformed JSON), refuses pre-parsed-object bodies, exercises the form encoder for nested + array params, exercises factory validation (`apiKey` + `webhookSecret` required, unknown adapter rejected), and exercises `createPaymentIntent` / `refund` input validation. Outbound API integration tests run against real Stripe in a later patch. **References:** [Stripe Webhooks](https://docs.stripe.com/webhooks) · [Stripe Payment Intents API](https://docs.stripe.com/api/payment_intents)

- v0.0.3 (2026-05-20) — **Tax + shipping primitives.** Pure-function tax and shipping computation against operator-declared rule tables. Both primitives ship a pluggable adapter contract; the `operatorTable` adapter built into v1 lets the framework run offline. Future Stripe Tax / TaxJar / EasyPost / Shippo adapters land as drop-in factory swaps without changing the call sites. **Added:** *`tax` — operator-table adapter* — `lib/tax.js` exposes `tax.create({ rules })` returning `{ name, calculate(ctx) }`. Rules declare `country` (ISO 3166-1 alpha-2), optional `state` and `postal_prefix`, and `rate_bps` (basis points — 1 bp = 0.01%). Match precedence is most-specific-first: country + state + postal_prefix → country + state → country → fallback (`rate_bps: 0`, `jurisdiction: "fallback"`). Tax computes against `subtotal_minor` with banker's rounding (round-half-to-even) so cumulative rounding bias across many small carts cancels out instead of drifting upward. The returned `jurisdiction` string (e.g. `"US/CA/941"`) is what storefront templates show under the tax line. · *`shipping` — operator-table adapter* — `lib/shipping.js` exposes `shipping.create({ services })` returning `{ name, rates(ctx) }`. Services declare an id, label, and zones; each zone is either flat-amount or per-gram (with optional `base_minor`, `min_amount_minor`, `max_amount_minor`). Free-over-threshold via `free_over_minor`. Zone matching uses the same most-specific-first precedence as tax. Digital-only services (`digital_only: true`) are offered only when every cart line has `requires_shipping: false`; physical services are filtered out when the cart is digital-only. `rates()` returns `{ services: [{ id, label, amount_minor, free, jurisdiction }], total_grams }` — the storefront renders one row per available service. **Detectors:** *Layer 1 — `tax`* — `test/layer-1-state/tax.test.js` covers rule validation, match precedence (postal > state > country > fallback), banker's rounding on .5 boundaries (round-half-to-even), shipTo / subtotal validation, and `subtotal × rate_bps / 10000` arithmetic on multiple bps values. · *Layer 1 — `shipping`* — `test/layer-1-state/shipping.test.js` covers flat-amount zones, per-gram zones with base + min/max clamping, free-over-threshold thresholding, zone specificity precedence, digital-only filtering, and rule-shape validation.

- v0.0.2 (2026-05-20) — **Cloudflare deploy + D1 backend + first commerce primitives.** First feature patch: complete Cloudflare deploy topology (Container + Worker + D1 + R2 + KV + Durable Objects), a `b.externalDb` adapter that targets D1 over the Worker service binding, and the first three commerce primitives — catalog (products, variants, prices, inventory, media), cart (anonymous + authenticated, price-snapshotted lines, merge on login), pricing (pure-function money math). All composed on blamejs primitives — UUIDs from `b.uuid.v7`, identifier safety from `b.guardUuid` and `b.guardMime`, cursor HMAC tagging from `b.pagination`, identifier quoting from `b.safeSql`, error responses via `b.problemDetails`, key derivation via `b.crypto.namespaceHash`. **Added:** *Cloudflare deploy topology* — `Dockerfile` (multi-stage Node 24-alpine build, non-root runtime, tini PID 1, in-image smoke gate), `.dockerignore`, and `wrangler.toml` wiring the Worker, the Container, a D1 database binding, an R2 assets bucket, a KV sessions namespace, and the Durable Object classes. Operators provision the resources with `wrangler d1 create` / `wrangler r2 bucket create` / `wrangler kv namespace create`, paste the returned ids into `wrangler.toml`, set five `BLAMEJS_*_PASSPHRASE` secrets via `wrangler secret put`, and run `wrangler deploy`. · *Edge router Worker* — `worker/index.js` — serves `/_/health` inline, passes `/assets/*` through to R2 with fingerprint-aware cache headers, verifies Stripe webhook signatures (`stripe-signature` HMAC-SHA256 over `<timestamp>.<body>` with a 5-minute tolerance window) before forwarding to the container, exposes the D1 query bridge under `/_/db/query` (gated by a shared-secret header), and forwards everything else to the container via the `Container` base class from `@cloudflare/containers` (handles lifecycle, readiness wait, port routing, scheme rewrite). · *InventoryLock Durable Object* — Per-SKU serialization point for stock decrement and release operations. Each SKU resolves to a single Durable Object instance so concurrent `decrement-if-available` calls from multiple container replicas queue safely; authoritative stock counts live in D1. Exposed as the `INVENTORY` binding. · *`externaldbD1` — Cloudflare D1 adapter for `b.externalDb`* — `lib/externaldb-d1.js` implements the `{ connect, query, close, dialect }` shape `b.externalDb.init` consumes, with two operating modes: service-binding (recommended — container reaches D1 through a sibling Worker, no D1 API token in the container) and rest-api (direct calls for development). Wire-protocol contract: SQLite parameter placeholders (`?1, ?2, ...`), `{ rows, rowCount, lastRowId }` result shape normalized across modes, AbortController-backed timeouts, jittered exponential retry on 5xx / 429 / transient network errors via `b.crypto.randomInt`. · *`catalog` — products, variants, prices, inventory, media* — `lib/catalog.js` ships five sub-modules composed on `b.externalDb` against the `0001_catalog.sql` schema. Products with slug + status (draft/active/archived), variants with options JSON + position + weight + shipping flag, versioned prices (set closes prior, multi-currency independent), inventory with stock-on-hand + held counts, media attached at product or variant scope with R2 keys and content-type validation. v7 UUID primary keys for B-tree locality. `b.guardUuid` for id validation (RFC 9562 threat catalog), `b.guardMime` for content-type validation, `b.safeSql.assertOneOf` + `b.safeSql.quoteIdentifier` for update-column allowlist, `b.pagination.encodeCursor` / `decodeCursor` for HMAC-tagged tuple cursors on `products.list`. · *`cart` — anonymous + authenticated carts* — `lib/cart.js` ships against the `0002_cart.sql` schema. Carts are keyed by `session_id` (sealed-cookie origin), survive container restarts (D1, not session memory), enforce one active cart per session via a partial unique index. `addLine` snapshots `unit_amount_minor` + `unit_currency` at add time from `catalog.prices.current()` (or an explicit override), de-duplicates by `(cart_id, variant_id)` by bumping qty. `merge(fromCartId, toCartId)` adopts an anonymous cart into a customer cart on login — line collisions sum qty, the source cart marks abandoned. `setStatus(cartId, 'converted')` makes a cart immutable post-checkout. · *`pricing` — pure-function money math* — `lib/pricing.js` ships `lineTotal(line)`, `subtotal(lines)`, `totals(cart, lines, ctx)`, and `format(amount_minor, currency, opts?)`. Every primitive operates in integer minor units (no floats), refuses to add across currencies (multi-currency carts throw), refuses negative discount/tax/shipping, and refuses discount > subtotal. `format()` composes `Intl.NumberFormat` for locale-correct rendering — zero-decimal currencies like JPY are handled automatically via the ISO 4217 minor-digit count. · *Read-only public catalog API* — `GET /api/catalog/products?status=&limit=&cursor=` returns a paginated product list (cursor is HMAC-tagged via `b.pagination`). `GET /api/catalog/products/:slug` returns one product with its variants + media. Errors surface as RFC 9457 problem documents via `b.problemDetails.respond` (TypeError → 400, missing slug → 404 with a stable `type` URN). Admin writes land later behind `lib/admin.js` (passkey + step-up); until then writes go through the wrangler CLI or direct D1 access. · *`docs/deploy-cloudflare.md`* — Operator-facing deploy recipe: one-time `wrangler` resource setup, secret management (`BLAMEJS_*_PASSPHRASE`, `D1_BRIDGE_SECRET`, `STRIPE_WEBHOOK_SECRET`), container env reference, `wrangler deploy`, verification curl commands, local development (`wrangler dev --remote` + `docker run` for offline smoke), and rollback flow. **Detectors:** *Layer 0 — `non-shop-require`* — `test/layer-0-primitives/codebase-patterns.test.js` adds a detector that refuses any `require()` in `lib/` whose target isn't a relative shop module (`./…` or `../…`). Forces operators to either compose with the blamejs equivalent (`b.crypto.sha3Hash` replaces `node:crypto.createHash`, `b.uuid.v7` replaces `node:crypto.randomUUID`, `b.safeUrl` replaces `node:url`, `b.atomicFile` replaces `node:fs` writes, etc.) or document the unavoidable dependency with a per-line `// allow:non-shop-require — <reason>` marker. Bare package names also surface here, reinforcing the zero-npm-runtime-deps rule. · *Layer 1 — `externaldb-d1` adapter contract* — `test/layer-1-state/externaldb-d1.test.js` covers factory validation, service-binding wire shape, rest-api wire shape, worker-side error envelope mapping, HTTP 5xx retry, HTTP 4xx terminal, transient network error retry, and AbortController-backed timeout surfacing as a `D1_TIMEOUT` typed error. · *Layer 1 — `catalog`* — `test/layer-1-state/catalog.test.js` runs every catalog op (products / variants / prices / inventory / media — create, read, list, update, archive/restore, cascade delete) against an in-memory `node:sqlite` database loaded from the live `0001_catalog.sql` so every CHECK / UNIQUE / FOREIGN KEY in the shipped schema is exercised end-to-end. · *Layer 1 — `cart`* — `test/layer-1-state/cart.test.js` exercises cart lifecycle (create, add/update/remove lines, listLines), price-snapshot fallback, partial-unique active-cart constraint, merge with collision/non-collision lines, customer attachment, post-conversion immutability, and validation error shapes. · *Layer 1 — `pricing`* — `test/layer-1-state/pricing.test.js` exercises `lineTotal`, `subtotal` (multi-currency refusal, empty-cart shape), `totals` (discount/tax/shipping arithmetic, discount-exceeds-subtotal refusal, cart-vs-lines currency mismatch refusal), and `format` against USD, EUR, and JPY (zero-decimal currency). **References:** [Cloudflare Containers](https://developers.cloudflare.com/containers/) · [Cloudflare D1](https://developers.cloudflare.com/d1/) · [blamejs externalDb](https://github.com/blamejs/blamejs/blob/main/lib/external-db.js)

- v0.0.1 (2026-05-19) — **Foundation.** Project scaffold for blamejs.shop — vendored blamejs framework + the release pipeline disciplines (structured release-notes generator, smoke-gated CHANGELOG rebuild, SLSA L3 provenance, Sigstore-keyless SBOM signatures, PQC sidecar signing). No feature surface yet; subsequent releases extend by composing the vendored blamejs primitives. **Added:** *Project skeleton* — Initial repository layout: `lib/vendor/` for the vendored blamejs tree, `scripts/` for the release-pipeline helpers (generator, consolidator, sha3-digest, sign-release-artifact, generate-release-signing-key, vendor-update.sh), `test/smoke.js` orchestrator, `test/layer-0-primitives/codebase-patterns.test.js` discipline gate, `release-notes/` JSON source of truth, plus the standard operator-facing artifacts (README, SECURITY, CHANGELOG, LICENSE). · *Vendor toolchain for blamejs* — `scripts/vendor-update.sh blamejs <tag>` shallow-clones the requested release tag from `github.com/blamejs/blamejs` into `lib/vendor/blamejs/` and updates `lib/vendor/MANIFEST.json` with the version + bundled-at date. The script's `--check` mode is wired into the smoke gate so drift between the vendored copy and the latest published blamejs release fails pre-push. · *Release pipeline disciplines* — Carries the same release flow blamejs runs: structured `release-notes/v<X>.json` generator with leak-vocabulary validation, consolidator that rolls non-current minors into `v<minor>.x.json`, `CHANGELOG.md` rebuilt end-to-end from the JSON tree, smoke gates on both `--check` modes so any drift between the JSONs and the markdown fails pre-push. **References:** [blamejs framework](https://github.com/blamejs/blamejs)
