@blamejs/blamejs-shop 0.0.53 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - 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.
12
+
13
+ - 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)).
14
+
15
+ - 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.
16
+
11
17
  - 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.
12
18
 
13
19
  - 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.
@@ -0,0 +1,430 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.addresses
4
+ * @title Customer addresses primitive — saved shipping/billing book
5
+ *
6
+ * @intro
7
+ * Persistent address book attached to a customer account. One row
8
+ * per saved address; the customer (via the storefront) or the
9
+ * operator (via admin) curates the list. The primitive owns the
10
+ * `customer_addresses` table introduced in migration
11
+ * `0026_customer_addresses.sql`.
12
+ *
13
+ * Composition:
14
+ * var addresses = bShop.addresses.create({ query: q });
15
+ * var row = await addresses.add({
16
+ * customer_id: customer.id,
17
+ * recipient_name: "Jane Doe",
18
+ * street_line1: "123 Main St",
19
+ * city: "Springfield",
20
+ * postal_code: "97001",
21
+ * country: "US",
22
+ * is_default_shipping: true,
23
+ * });
24
+ * var ship = await addresses.defaultShipping(customer.id);
25
+ *
26
+ * Default-flag uniqueness is enforced at write time inside `.add`,
27
+ * `.update`, `.setDefaultShipping`, and `.setDefaultBilling`. Each
28
+ * call clears the matching flag on the customer's other
29
+ * (non-archived) rows before promoting the named row, so the
30
+ * "is there a default shipping address?" question always resolves
31
+ * to zero or one row without a partial UNIQUE index.
32
+ *
33
+ * `country` is ISO 3166-1 alpha-2 — two uppercase letters. The
34
+ * primitive enforces the character class with `/^[A-Z]{2}$/`;
35
+ * strict country-list membership (e.g. excluding retired codes or
36
+ * restricted-jurisdiction subdivisions) is operator policy and
37
+ * layers on top.
38
+ *
39
+ * `phone` is optional E.164-ish — leading `+` is permitted, the
40
+ * first digit must be non-zero, and the digit run is 2-15 long.
41
+ * Storage is plaintext so the operator can display the number back
42
+ * to staff during outbound contact.
43
+ *
44
+ * `is_archived` is the soft-delete flag. Archived rows are excluded
45
+ * from `listForCustomer` / `defaultShipping` / `defaultBilling` /
46
+ * `matchByContent` by default — passing `include_archived: true`
47
+ * to `listForCustomer` opts them back in for an admin audit.
48
+ * Archived rows can be promoted back via `.unarchive`.
49
+ *
50
+ * @related customers, checkout
51
+ */
52
+
53
+ var bShop;
54
+ function _b() {
55
+ if (!bShop) bShop = require("./index");
56
+ return bShop.framework;
57
+ }
58
+
59
+ var MAX_LABEL_LEN = 64;
60
+ var MAX_RECIPIENT_NAME_LEN = 128;
61
+ var MAX_COMPANY_LEN = 128;
62
+ var MAX_STREET_LEN = 256;
63
+ var MAX_CITY_LEN = 128;
64
+ var MAX_REGION_LEN = 128;
65
+ var MAX_POSTAL_LEN = 32;
66
+ var MAX_PHONE_LEN = 32;
67
+
68
+ var COUNTRY_RE = /^[A-Z]{2}$/;
69
+ var PHONE_RE = /^\+?[1-9]\d{1,14}$/;
70
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
71
+
72
+ // ---- validators ---------------------------------------------------------
73
+
74
+ function _uuid(s, label) {
75
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
76
+ catch (e) { throw new TypeError("addresses: " + label + " — " + (e && e.message || "invalid UUID")); }
77
+ }
78
+
79
+ function _str(value, label, max, opts) {
80
+ opts = opts || {};
81
+ var required = opts.required !== false;
82
+ if (value == null) {
83
+ if (required) throw new TypeError("addresses: " + label + " is required");
84
+ return "";
85
+ }
86
+ if (typeof value !== "string") {
87
+ throw new TypeError("addresses: " + label + " must be a string");
88
+ }
89
+ var trimmed = value.trim();
90
+ if (required && !trimmed.length) {
91
+ throw new TypeError("addresses: " + label + " must be a non-empty string");
92
+ }
93
+ if (trimmed.length > max) {
94
+ throw new TypeError("addresses: " + label + " must be <= " + max + " characters");
95
+ }
96
+ if (CONTROL_BYTE_RE.test(trimmed)) {
97
+ throw new TypeError("addresses: " + label + " contains control bytes");
98
+ }
99
+ return trimmed;
100
+ }
101
+
102
+ function _country(value) {
103
+ if (typeof value !== "string" || !COUNTRY_RE.test(value)) {
104
+ throw new TypeError("addresses: country must be ISO 3166-1 alpha-2 (two uppercase letters)");
105
+ }
106
+ return value;
107
+ }
108
+
109
+ function _phone(value) {
110
+ if (value == null || value === "") return "";
111
+ if (typeof value !== "string") {
112
+ throw new TypeError("addresses: phone must be a string");
113
+ }
114
+ var trimmed = value.trim();
115
+ if (!trimmed.length) return "";
116
+ if (trimmed.length > MAX_PHONE_LEN) {
117
+ throw new TypeError("addresses: phone must be <= " + MAX_PHONE_LEN + " characters");
118
+ }
119
+ if (!PHONE_RE.test(trimmed)) {
120
+ throw new TypeError("addresses: phone must match E.164-ish shape (^\\+?[1-9]\\d{1,14}$)");
121
+ }
122
+ return trimmed;
123
+ }
124
+
125
+ function _bool(value, label) {
126
+ if (value == null) return 0;
127
+ if (value === true || value === 1) return 1;
128
+ if (value === false || value === 0) return 0;
129
+ throw new TypeError("addresses: " + label + " must be a boolean");
130
+ }
131
+
132
+ function _now() { return Date.now(); }
133
+
134
+ // ---- factory ------------------------------------------------------------
135
+
136
+ function create(opts) {
137
+ opts = opts || {};
138
+ var query = opts.query;
139
+ if (!query) {
140
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
141
+ }
142
+
143
+ // Clear the named default flag on every non-archived row for this
144
+ // customer EXCEPT the optionally-excluded id. Used before promoting
145
+ // a new default so the table's one-default-per-role invariant holds
146
+ // without a partial UNIQUE index.
147
+ async function _clearDefault(customerId, column, exceptId) {
148
+ var sql =
149
+ "UPDATE customer_addresses SET " + column + " = 0, updated_at = ?1 " +
150
+ "WHERE customer_id = ?2 AND " + column + " = 1 AND is_archived = 0";
151
+ var params = [_now(), customerId];
152
+ if (exceptId) {
153
+ sql += " AND id != ?3";
154
+ params.push(exceptId);
155
+ }
156
+ await query(sql, params);
157
+ }
158
+
159
+ async function _getRow(id) {
160
+ var r = await query("SELECT * FROM customer_addresses WHERE id = ?1", [id]);
161
+ return r.rows[0] || null;
162
+ }
163
+
164
+ return {
165
+ add: async function (input) {
166
+ if (!input || typeof input !== "object") {
167
+ throw new TypeError("addresses.add: input object required");
168
+ }
169
+ var customerId = _uuid(input.customer_id, "customer_id");
170
+ var label = _str(input.label, "label", MAX_LABEL_LEN, { required: false });
171
+ var recipientName = _str(input.recipient_name, "recipient_name", MAX_RECIPIENT_NAME_LEN);
172
+ var company = _str(input.company, "company", MAX_COMPANY_LEN, { required: false });
173
+ var streetLine1 = _str(input.street_line1, "street_line1", MAX_STREET_LEN);
174
+ var streetLine2 = _str(input.street_line2, "street_line2", MAX_STREET_LEN, { required: false });
175
+ var city = _str(input.city, "city", MAX_CITY_LEN);
176
+ var region = _str(input.region, "region", MAX_REGION_LEN, { required: false });
177
+ var postalCode = _str(input.postal_code, "postal_code", MAX_POSTAL_LEN);
178
+ var country = _country(input.country);
179
+ var phone = _phone(input.phone);
180
+ var defShipping = _bool(input.is_default_shipping, "is_default_shipping");
181
+ var defBilling = _bool(input.is_default_billing, "is_default_billing");
182
+
183
+ var id = _b().uuid.v7();
184
+ var ts = _now();
185
+
186
+ // Clear sibling defaults FIRST so the promotion lands cleanly.
187
+ if (defShipping) await _clearDefault(customerId, "is_default_shipping", null);
188
+ if (defBilling) await _clearDefault(customerId, "is_default_billing", null);
189
+
190
+ await query(
191
+ "INSERT INTO customer_addresses (" +
192
+ " id, customer_id, label, recipient_name, company," +
193
+ " street_line1, street_line2, city, region, postal_code, country, phone," +
194
+ " is_default_shipping, is_default_billing, is_archived," +
195
+ " created_at, updated_at" +
196
+ ") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0, ?15, ?15)",
197
+ [
198
+ id, customerId, label, recipientName, company,
199
+ streetLine1, streetLine2, city, region, postalCode, country, phone,
200
+ defShipping, defBilling, ts,
201
+ ],
202
+ );
203
+ return await _getRow(id);
204
+ },
205
+
206
+ update: async function (addressId, patch) {
207
+ _uuid(addressId, "address_id");
208
+ if (!patch || typeof patch !== "object") {
209
+ throw new TypeError("addresses.update: patch object required");
210
+ }
211
+ var existing = await _getRow(addressId);
212
+ if (!existing) {
213
+ throw new TypeError("addresses.update: address " + addressId + " not found");
214
+ }
215
+
216
+ // Build the column list from whichever keys are actually
217
+ // present in the patch. Each branch validates the new value
218
+ // against the same rules `.add` applies. Keys absent from the
219
+ // patch are left untouched.
220
+ var sets = [];
221
+ var params = [];
222
+ function _push(col, val) {
223
+ params.push(val);
224
+ sets.push(col + " = ?" + params.length);
225
+ }
226
+
227
+ if (Object.prototype.hasOwnProperty.call(patch, "label")) {
228
+ _push("label", _str(patch.label, "label", MAX_LABEL_LEN, { required: false }));
229
+ }
230
+ if (Object.prototype.hasOwnProperty.call(patch, "recipient_name")) {
231
+ _push("recipient_name", _str(patch.recipient_name, "recipient_name", MAX_RECIPIENT_NAME_LEN));
232
+ }
233
+ if (Object.prototype.hasOwnProperty.call(patch, "company")) {
234
+ _push("company", _str(patch.company, "company", MAX_COMPANY_LEN, { required: false }));
235
+ }
236
+ if (Object.prototype.hasOwnProperty.call(patch, "street_line1")) {
237
+ _push("street_line1", _str(patch.street_line1, "street_line1", MAX_STREET_LEN));
238
+ }
239
+ if (Object.prototype.hasOwnProperty.call(patch, "street_line2")) {
240
+ _push("street_line2", _str(patch.street_line2, "street_line2", MAX_STREET_LEN, { required: false }));
241
+ }
242
+ if (Object.prototype.hasOwnProperty.call(patch, "city")) {
243
+ _push("city", _str(patch.city, "city", MAX_CITY_LEN));
244
+ }
245
+ if (Object.prototype.hasOwnProperty.call(patch, "region")) {
246
+ _push("region", _str(patch.region, "region", MAX_REGION_LEN, { required: false }));
247
+ }
248
+ if (Object.prototype.hasOwnProperty.call(patch, "postal_code")) {
249
+ _push("postal_code", _str(patch.postal_code, "postal_code", MAX_POSTAL_LEN));
250
+ }
251
+ if (Object.prototype.hasOwnProperty.call(patch, "country")) {
252
+ _push("country", _country(patch.country));
253
+ }
254
+ if (Object.prototype.hasOwnProperty.call(patch, "phone")) {
255
+ _push("phone", _phone(patch.phone));
256
+ }
257
+
258
+ var promoteShipping = false;
259
+ var promoteBilling = false;
260
+ if (Object.prototype.hasOwnProperty.call(patch, "is_default_shipping")) {
261
+ var ds = _bool(patch.is_default_shipping, "is_default_shipping");
262
+ _push("is_default_shipping", ds);
263
+ promoteShipping = ds === 1;
264
+ }
265
+ if (Object.prototype.hasOwnProperty.call(patch, "is_default_billing")) {
266
+ var db = _bool(patch.is_default_billing, "is_default_billing");
267
+ _push("is_default_billing", db);
268
+ promoteBilling = db === 1;
269
+ }
270
+
271
+ if (!sets.length) return existing;
272
+
273
+ // Clear sibling defaults BEFORE the row's own update so we
274
+ // don't accidentally clear the freshly-promoted row.
275
+ if (promoteShipping) await _clearDefault(existing.customer_id, "is_default_shipping", addressId);
276
+ if (promoteBilling) await _clearDefault(existing.customer_id, "is_default_billing", addressId);
277
+
278
+ _push("updated_at", _now());
279
+ params.push(addressId);
280
+ await query(
281
+ "UPDATE customer_addresses SET " + sets.join(", ") + " WHERE id = ?" + params.length,
282
+ params,
283
+ );
284
+ return await _getRow(addressId);
285
+ },
286
+
287
+ get: async function (addressId) {
288
+ _uuid(addressId, "address_id");
289
+ return await _getRow(addressId);
290
+ },
291
+
292
+ // Default ordering: default-shipping rows first, then
293
+ // default-billing, then by created_at DESC. The operator-facing
294
+ // contract is "the customer's most recent / most-blessed
295
+ // addresses surface at the top of an account-page render".
296
+ listForCustomer: async function (customerId, listOpts) {
297
+ _uuid(customerId, "customer_id");
298
+ listOpts = listOpts || {};
299
+ var includeArchived = listOpts.include_archived === true;
300
+ var sql =
301
+ "SELECT * FROM customer_addresses " +
302
+ "WHERE customer_id = ?1" +
303
+ (includeArchived ? "" : " AND is_archived = 0") +
304
+ " ORDER BY is_default_shipping DESC, is_default_billing DESC, created_at DESC";
305
+ var r = await query(sql, [customerId]);
306
+ return r.rows;
307
+ },
308
+
309
+ defaultShipping: async function (customerId) {
310
+ _uuid(customerId, "customer_id");
311
+ var r = await query(
312
+ "SELECT * FROM customer_addresses " +
313
+ "WHERE customer_id = ?1 AND is_default_shipping = 1 AND is_archived = 0 " +
314
+ "LIMIT 1",
315
+ [customerId],
316
+ );
317
+ return r.rows[0] || null;
318
+ },
319
+
320
+ defaultBilling: async function (customerId) {
321
+ _uuid(customerId, "customer_id");
322
+ var r = await query(
323
+ "SELECT * FROM customer_addresses " +
324
+ "WHERE customer_id = ?1 AND is_default_billing = 1 AND is_archived = 0 " +
325
+ "LIMIT 1",
326
+ [customerId],
327
+ );
328
+ return r.rows[0] || null;
329
+ },
330
+
331
+ archive: async function (addressId) {
332
+ _uuid(addressId, "address_id");
333
+ var existing = await _getRow(addressId);
334
+ if (!existing) return false;
335
+ // Archiving the current default for a role also drops the flag
336
+ // — leaving an archived row as the "default" would let the
337
+ // listForCustomer / defaultShipping pair disagree on whether a
338
+ // default exists.
339
+ await query(
340
+ "UPDATE customer_addresses SET is_archived = 1, " +
341
+ "is_default_shipping = 0, is_default_billing = 0, updated_at = ?1 " +
342
+ "WHERE id = ?2",
343
+ [_now(), addressId],
344
+ );
345
+ return true;
346
+ },
347
+
348
+ unarchive: async function (addressId) {
349
+ _uuid(addressId, "address_id");
350
+ var existing = await _getRow(addressId);
351
+ if (!existing) return false;
352
+ await query(
353
+ "UPDATE customer_addresses SET is_archived = 0, updated_at = ?1 WHERE id = ?2",
354
+ [_now(), addressId],
355
+ );
356
+ return true;
357
+ },
358
+
359
+ setDefaultShipping: async function (addressId) {
360
+ _uuid(addressId, "address_id");
361
+ var existing = await _getRow(addressId);
362
+ if (!existing) {
363
+ throw new TypeError("addresses.setDefaultShipping: address " + addressId + " not found");
364
+ }
365
+ if (existing.is_archived === 1) {
366
+ throw new TypeError("addresses.setDefaultShipping: address is archived — unarchive first");
367
+ }
368
+ await _clearDefault(existing.customer_id, "is_default_shipping", addressId);
369
+ await query(
370
+ "UPDATE customer_addresses SET is_default_shipping = 1, updated_at = ?1 WHERE id = ?2",
371
+ [_now(), addressId],
372
+ );
373
+ return await _getRow(addressId);
374
+ },
375
+
376
+ setDefaultBilling: async function (addressId) {
377
+ _uuid(addressId, "address_id");
378
+ var existing = await _getRow(addressId);
379
+ if (!existing) {
380
+ throw new TypeError("addresses.setDefaultBilling: address " + addressId + " not found");
381
+ }
382
+ if (existing.is_archived === 1) {
383
+ throw new TypeError("addresses.setDefaultBilling: address is archived — unarchive first");
384
+ }
385
+ await _clearDefault(existing.customer_id, "is_default_billing", addressId);
386
+ await query(
387
+ "UPDATE customer_addresses SET is_default_billing = 1, updated_at = ?1 WHERE id = ?2",
388
+ [_now(), addressId],
389
+ );
390
+ return await _getRow(addressId);
391
+ },
392
+
393
+ // Operator-side dedup helper. The address book grows quickly when
394
+ // a customer types a slight variation of an address they already
395
+ // have saved (extra apartment number, different casing on city,
396
+ // etc.); the storefront uses this to surface "you already have an
397
+ // address with this postal_code + country" before the customer
398
+ // confirms the save. Returns every non-archived row that matches
399
+ // the customer + postal_code + country triple — the caller
400
+ // decides whether the recipient_name / street_line1 are close
401
+ // enough to merge.
402
+ matchByContent: async function (input) {
403
+ if (!input || typeof input !== "object") {
404
+ throw new TypeError("addresses.matchByContent: input object required");
405
+ }
406
+ var customerId = _uuid(input.customer_id, "customer_id");
407
+ var postal = _str(input.postal_code, "postal_code", MAX_POSTAL_LEN);
408
+ var country = _country(input.country);
409
+ var r = await query(
410
+ "SELECT * FROM customer_addresses " +
411
+ "WHERE customer_id = ?1 AND postal_code = ?2 AND country = ?3 AND is_archived = 0 " +
412
+ "ORDER BY created_at DESC",
413
+ [customerId, postal, country],
414
+ );
415
+ return r.rows;
416
+ },
417
+ };
418
+ }
419
+
420
+ module.exports = {
421
+ create: create,
422
+ MAX_LABEL_LEN: MAX_LABEL_LEN,
423
+ MAX_RECIPIENT_NAME_LEN: MAX_RECIPIENT_NAME_LEN,
424
+ MAX_COMPANY_LEN: MAX_COMPANY_LEN,
425
+ MAX_STREET_LEN: MAX_STREET_LEN,
426
+ MAX_CITY_LEN: MAX_CITY_LEN,
427
+ MAX_REGION_LEN: MAX_REGION_LEN,
428
+ MAX_POSTAL_LEN: MAX_POSTAL_LEN,
429
+ MAX_PHONE_LEN: MAX_PHONE_LEN,
430
+ };