@blamejs/blamejs-shop 0.3.69 → 0.3.71

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/lib/admin.js +254 -1
  4. package/lib/asset-manifest.json +1 -1
  5. package/lib/vendor/MANIFEST.json +95 -83
  6. package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
  7. package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +1 -1
  8. package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +1 -1
  9. package/lib/vendor/blamejs/.github/workflows/ci.yml +10 -10
  10. package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
  11. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +2 -2
  12. package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
  13. package/lib/vendor/blamejs/.github/workflows/scorecard.yml +2 -2
  14. package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
  15. package/lib/vendor/blamejs/CHANGELOG.md +4 -0
  16. package/lib/vendor/blamejs/README.md +1 -1
  17. package/lib/vendor/blamejs/SECURITY.md +2 -0
  18. package/lib/vendor/blamejs/api-snapshot.json +108 -4
  19. package/lib/vendor/blamejs/lib/auth/oauth.js +736 -1
  20. package/lib/vendor/blamejs/lib/auth/oid4vci.js +124 -5
  21. package/lib/vendor/blamejs/lib/auth/oid4vp.js +14 -4
  22. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc-holder.js +46 -1
  23. package/lib/vendor/blamejs/lib/break-glass.js +1 -2
  24. package/lib/vendor/blamejs/lib/config.js +28 -31
  25. package/lib/vendor/blamejs/lib/crypto-field.js +274 -17
  26. package/lib/vendor/blamejs/lib/dora.js +8 -5
  27. package/lib/vendor/blamejs/lib/dsr.js +2 -2
  28. package/lib/vendor/blamejs/lib/flag-evaluation-context.js +7 -0
  29. package/lib/vendor/blamejs/lib/guard-html-wcag-aria.js +4 -2
  30. package/lib/vendor/blamejs/lib/guard-html-wcag-forms.js +4 -2
  31. package/lib/vendor/blamejs/lib/guard-html-wcag-tables.js +4 -2
  32. package/lib/vendor/blamejs/lib/guard-html-wcag-tagwalk.js +20 -0
  33. package/lib/vendor/blamejs/lib/guard-html-wcag.js +1 -1
  34. package/lib/vendor/blamejs/lib/honeytoken.js +27 -20
  35. package/lib/vendor/blamejs/lib/mail-auth.js +333 -0
  36. package/lib/vendor/blamejs/lib/mail-deploy.js +1 -1
  37. package/lib/vendor/blamejs/lib/mail-send-deliver.js +13 -4
  38. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +140 -13
  39. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -0
  40. package/lib/vendor/blamejs/lib/middleware/csp-report.js +13 -9
  41. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +115 -14
  42. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -0
  43. package/lib/vendor/blamejs/lib/middleware/scim-server.js +297 -19
  44. package/lib/vendor/blamejs/lib/middleware/security-headers.js +47 -0
  45. package/lib/vendor/blamejs/lib/middleware/security-txt.js +1 -2
  46. package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +1 -2
  47. package/lib/vendor/blamejs/lib/network-smtp-policy.js +4 -4
  48. package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +11 -2
  49. package/lib/vendor/blamejs/lib/observability-tracer.js +1 -1
  50. package/lib/vendor/blamejs/lib/observability.js +39 -1
  51. package/lib/vendor/blamejs/lib/problem-details.js +56 -11
  52. package/lib/vendor/blamejs/lib/pubsub-cluster.js +16 -3
  53. package/lib/vendor/blamejs/lib/queue-sqs.js +20 -2
  54. package/lib/vendor/blamejs/lib/redis-client.js +32 -4
  55. package/lib/vendor/blamejs/lib/safe-redirect.js +16 -2
  56. package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +3 -2
  57. package/lib/vendor/blamejs/package.json +1 -1
  58. package/lib/vendor/blamejs/release-notes/v0.14.20.json +73 -0
  59. package/lib/vendor/blamejs/release-notes/v0.14.21.json +98 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +339 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +37 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +22 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +315 -5
  64. package/lib/vendor/blamejs/test/layer-0-primitives/config.test.js +46 -0
  65. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-unseal-rate-cap.test.js +176 -0
  66. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +86 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/dora.test.js +38 -0
  68. package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +29 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +236 -1
  70. package/lib/vendor/blamejs/test/layer-0-primitives/fetch-metadata.test.js +190 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +23 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/guard-html-wcag.test.js +59 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/honeytoken.test.js +26 -0
  74. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy-tlsrpt.test.js +16 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +108 -0
  77. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +269 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +28 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +39 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +37 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/problem-details.test.js +79 -0
  82. package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +49 -0
  83. package/lib/vendor/blamejs/test/layer-0-primitives/queue-sqs.test.js +48 -0
  84. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +60 -0
  85. package/lib/vendor/blamejs/test/layer-0-primitives/safe-redirect.test.js +118 -0
  86. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +259 -0
  87. package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +46 -0
  88. package/lib/vendor/blamejs/test/layer-0-primitives/security-headers.test.js +113 -0
  89. package/lib/vendor/blamejs/test/layer-0-primitives/security-txt.test.js +111 -0
  90. package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +62 -0
  91. package/lib/vendor/blamejs/test/layer-0-primitives/smtp-policy.test.js +39 -0
  92. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.71 (2026-06-05) — **Vendored blamejs framework refreshed from v0.14.19 to v0.14.21.** The storefront runs on a vendored copy of the blamejs framework; this refreshes it across two upstream patch releases. The most operator-relevant upstream changes for this shop: the OAuth client — which Sign in with Google and Apple compose — gains RFC 9396 Rich Authorization Request validation and attestation-based client authentication primitives, and refuses token grants an identity provider broadened beyond what was requested; HEAD requests now conform by carrying no response body; the sealed-field store gains an unseal rate cap; and a framework-wide sweep ensures every accepted option is actually read, surfacing configuration typos that were previously silent. The remaining upstream changes are in framework areas the storefront does not expose (SCIM bulk operations, OID4VCI credential proofs, DMARC forensic-report parsing). The storefront's own behavior is unchanged, verified by the full test suite — including the regression guards that pin the CSRF origin posture, the referrer policy, and the payment-processor TLS agent against vendor drift — and the vendored-tree integrity manifest was re-stamped as part of the refresh. **Changed:** *Updated the vendored framework to blamejs v0.14.21* — The vendored framework moves from v0.14.19 to v0.14.21 (two upstream patch releases of fixes and additive, opt-in changes). Notable for this shop: hardened OAuth client validation behind federated sign-in, HTTP HEAD conformance, a sealed-field unseal rate cap, and boot-time validation of previously-unread options. The integrity manifest over the vendored tree was re-stamped as part of the refresh.
12
+
13
+ - v0.3.70 (2026-06-05) — **An Analytics screen in the admin console: funnel, search terms, and product views.** The admin console gains a read-only Analytics screen at /admin/analytics showing the pre-purchase signal the sales report cannot see: the browse-to-buy funnel (product views to cart adds to checkout starts to completed orders) with a conversion rate, top search terms, most-viewed products, top SKUs ranked by units sold, and a revenue-by-day sparkline. It complements rather than duplicates the existing Reports screen — revenue totals stay there, and the two screens link to each other. The same path answers a bearer-token request with JSON, and three JSON endpoints expose the funnel, search terms, and viewed products individually for tooling. Every aggregate is window-bounded (one year maximum, thirty-day default) and limit-bounded; the screen adds no write path anywhere. **Added:** *Read-only analytics dashboard* — The analytics primitive's aggregates are now wired into the console: a date-windowed view with the conversion funnel, top search terms, most-viewed products, units-ranked top SKUs, recent revenue trend, and cross-links with the Reports screen. The HTML view degrades a malformed date window to the default with a notice; the JSON surfaces reject it with a 400. Limits clamp to the primitive's own bounds, a refund-heavy window renders a signed negative total instead of failing, and every operator- or customer-derived string on the screen is escaped. The screen appears in the navigation only when the primitive is wired.
14
+
11
15
  - v0.3.69 (2026-06-05) — **Discount codes on the cart page, and the full shipment timeline on the order page.** Shoppers can now redeem a discount code: an automatic-discount rule may carry an unlock code, leaving it dormant until a shopper enters that code on the cart page — the code persists on the cart, survives signing in, and flows through the exact same evaluation, quoting, and charge path as every other discount rule, so there is one discount system, not two. The cart shows the applied code with a remove control, totals re-render with the savings, and an unknown code gets one uniform message. Separately, the customer order page now shows the full shipment tracking timeline — every recorded carrier event with its status, location, and time, newest first — instead of only the latest event. Two schema migrations apply on deploy. **Added:** *Code-unlocked discount rules with cart-page redemption* — An automatic-discount rule can now carry an unlock code (one active rule per code). The rule stays dormant until a shopper enters its code in the new entry form on the cart page; the code is stored on the cart (capped, idempotent, carried across sign-in by the cart merge), totals re-render with the rule applied, and checkout's quote and confirm honor it on every payment path. The applied code renders as a chip with a remove control; unknown, malformed, or inactive codes all get the same message, so the endpoint reveals nothing about which codes exist. The redemption form is CSRF-tokened like the other cart configuration forms, and the cart page's edge-cached anonymous shell is unaffected — code state renders only on the container. · *Full shipment tracking timeline for customers* — The order page's tracking card previously showed the carrier, tracking number, status, and only the most recent event. It now renders the full event timeline newest-first — status, optional carrier location, optional note, and the event time — bounded to the newest twenty events. Orders without shipment events render exactly as before.
12
16
 
13
17
  - v0.3.68 (2026-06-05) — **Delivery estimates: "Get it by" dates on the product and cart pages.** The product page and cart can now promise a delivery date. Operators configure the inputs at /admin/delivery-estimates — carrier transit times, warehouse cutoff hours, shipping holidays, and postal-prefix zones — and name the origin warehouse via the SHOP_ESTIMATE_ORIGIN environment variable or the shop.estimate_origin config row. A signed-in customer with a saved shipping address then sees "Get it by <date>" computed from the cutoff clock, transit days, and the holiday calendar. Anonymous visitors served from the edge cache deliberately see no date: an estimate is destination-specific, and a date baked into a shared cached page would show one visitor's delivery promise to everyone while going stale in the cache. The product and cart markup builders are byte-identical across the edge and container renderers with a deterministic date formatter, locked by a parity test. A store that has not configured estimates renders exactly what it does today — nothing — and a configuration gap can never produce a customer-facing error. **Added:** *Configurable delivery-date estimates* — The delivery-estimate primitive is wired end to end: an /admin/delivery-estimates console manages carrier transit times, order-cutoff hours per warehouse, shipping holidays, and postal-prefix zone mappings, with each mutation audited; the product and cart pages render "Get it by <date>" for signed-in customers with a saved shipping address once an origin warehouse is configured. Estimates render only where the destination is actually known — never on shared edge-cached pages — and every estimate read is fail-quiet: missing configuration, an unmatched destination, or a computation error renders no estimate rather than an error. Operators enable it by setting the origin (SHOP_ESTIMATE_ORIGIN or the shop.estimate_origin config row) and authoring at least one cutoff, transit time, and postal zone.
package/README.md CHANGED
@@ -97,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
97
97
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
98
98
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
99
99
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
100
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
100
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
101
101
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
102
102
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
103
103
 
package/lib/admin.js CHANGED
@@ -573,7 +573,7 @@ function mount(router, deps) {
573
573
  // `reports` is always present in the nav (read-only sales summary needs no
574
574
  // extra dep); its route mounts unconditionally and renders an unconfigured
575
575
  // notice when the salesReports primitive isn't wired.
576
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
576
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
577
577
 
578
578
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
579
579
 
@@ -7896,6 +7896,142 @@ function mount(router, deps) {
7896
7896
  nav_available: navAvailable,
7897
7897
  }));
7898
7898
  });
7899
+
7900
+ // Product-performance + behaviour screen at /admin/analytics. Complements
7901
+ // /admin/reports: the report screen owns the orders-derived money view
7902
+ // (gross/net/AOV/refund-rate, the order-FSM status funnel, top products
7903
+ // by revenue, the by-day money table). This screen owns what the report
7904
+ // does NOT show — units-ranked SKU performance, the pre-purchase
7905
+ // browse→buy funnel (PDP→cart→checkout→complete) and conversion rate,
7906
+ // most-viewed products, top search terms, and a revenue-by-day trend
7907
+ // sparkline. The two link to each other.
7908
+ //
7909
+ // Date range mirrors /admin/reports: `from`/`to` epoch-ms OR
7910
+ // `from-date`/`to-date` calendar-date params (the browser date inputs),
7911
+ // default last 30 days. The analytics primitive re-validates each window
7912
+ // (since < until, span ≤ 1 year) and refuses an unbounded scan, so the
7913
+ // worst an operator typo does is a 400-with-correction re-render — never
7914
+ // a 500, never an unbounded query.
7915
+
7916
+ // Resolve the analytics window from the query string into BOTH the
7917
+ // orders-derived `{ since, until }` shape and the event-stream
7918
+ // `{ from, to }` shape (the primitive uses different bound names per
7919
+ // surface). Accepts the raw epoch-ms `from`/`to` pair (machine clients)
7920
+ // or the calendar-date `from-date`/`to-date` pair (browser date inputs);
7921
+ // epoch-ms wins when both are present. Throws TypeError on a malformed
7922
+ // value so the config/entry-tier 400 path catches it; the primitive's own
7923
+ // span/order re-validation is the second gate.
7924
+ function _parseAnalyticsWindow(url) {
7925
+ var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
7926
+ var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
7927
+ if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
7928
+ if (to == null) {
7929
+ var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
7930
+ // `to-date` is the inclusive end day — advance to the next UTC
7931
+ // midnight so the window covers the whole selected day (the primitive
7932
+ // treats `until`/`to` as exclusive).
7933
+ if (toDate != null) to = toDate + b.constants.TIME.days(1);
7934
+ }
7935
+ var now = Date.now();
7936
+ if (to == null) to = now;
7937
+ if (from == null) from = now - b.constants.TIME.days(30);
7938
+ return { from: from, to: to };
7939
+ }
7940
+
7941
+ // Compose the screen payload from the analytics primitive. Every read
7942
+ // carries the primitive's own window/limit bound: `topSKUs` /
7943
+ // `topViewedProducts` / `topSearchTerms` are capped at limit 10 (the
7944
+ // primitive enforces [1,100]); `revenueByDay` and `funnel` are window-
7945
+ // bounded (the primitive enforces span ≤ 1 year). No unbounded read is
7946
+ // exposed by this screen.
7947
+ async function _buildAnalyticsView(win) {
7948
+ var ordersWin = { since: win.from, until: win.to };
7949
+ var eventsWin = { from: win.from, to: win.to };
7950
+ var topSkus = await analytics.topSKUs(Object.assign({}, ordersWin, { limit: 10 }));
7951
+ var byDay = await analytics.revenueByDay(ordersWin);
7952
+ var viewed = await analytics.topViewedProducts(Object.assign({}, eventsWin, { limit: 10 }));
7953
+ var searches = await analytics.topSearchTerms(Object.assign({}, eventsWin, { limit: 10 }));
7954
+ var funnel = await analytics.funnel(eventsWin);
7955
+ // Headline currency for the sparkline + SKU revenue column: the first
7956
+ // currency the by-day series touched (alphabetical), USD when empty.
7957
+ var currency = "USD";
7958
+ for (var i = 0; i < byDay.length; i += 1) {
7959
+ if (byDay[i].currency) { currency = byDay[i].currency; break; }
7960
+ }
7961
+ return {
7962
+ from: win.from,
7963
+ to: win.to,
7964
+ currency: currency,
7965
+ top_skus: topSkus,
7966
+ by_day: byDay,
7967
+ top_viewed_products: viewed,
7968
+ top_search_terms: searches,
7969
+ funnel: funnel,
7970
+ };
7971
+ }
7972
+
7973
+ // JSON-only complements to the existing /admin/analytics/* API — the
7974
+ // pre-purchase aggregates the dashboard/reports JSON never exposed.
7975
+ router.get("/admin/analytics/top-search-terms", R(async function (req, res) {
7976
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7977
+ var w = _parseWindow(url);
7978
+ w.from = w.since; w.to = w.until; delete w.since; delete w.until;
7979
+ w.limit = _parseLimit(url && url.searchParams.get("limit"), "limit", 100, 10);
7980
+ var rows = await analytics.topSearchTerms(w);
7981
+ _json(res, 200, { rows: rows });
7982
+ }));
7983
+
7984
+ router.get("/admin/analytics/top-viewed-products", R(async function (req, res) {
7985
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7986
+ var w = _parseWindow(url);
7987
+ w.from = w.since; w.to = w.until; delete w.since; delete w.until;
7988
+ w.limit = _parseLimit(url && url.searchParams.get("limit"), "limit", 100, 10);
7989
+ var rows = await analytics.topViewedProducts(w);
7990
+ _json(res, 200, { rows: rows });
7991
+ }));
7992
+
7993
+ router.get("/admin/analytics/funnel", R(async function (req, res) {
7994
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7995
+ var w = _parseWindow(url);
7996
+ var f = await analytics.funnel({ from: w.since, to: w.until });
7997
+ _json(res, 200, f);
7998
+ }));
7999
+
8000
+ // The product-performance screen — bearer → JSON contract, signed-in
8001
+ // browser → rendered console page.
8002
+ router.get("/admin/analytics", _pageOrApi(true,
8003
+ R(async function (req, res) {
8004
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
8005
+ var win;
8006
+ try { win = _parseAnalyticsWindow(url); }
8007
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
8008
+ var view;
8009
+ try { view = await _buildAnalyticsView(win); }
8010
+ catch (e2) { if (e2 instanceof TypeError) return _problem(res, 400, "bad-request", e2.message); throw e2; }
8011
+ _json(res, 200, view);
8012
+ }),
8013
+ async function (req, res) {
8014
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
8015
+ var win, notice = null;
8016
+ try { win = _parseAnalyticsWindow(url); }
8017
+ catch (e) {
8018
+ // Bad range → default window + a correction notice (config/entry
8019
+ // tier: the operator fixes the typo, the screen never 500s).
8020
+ win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
8021
+ notice = _safeNotice(e, "analytics.view").message.replace(/^admin:\s*/, "");
8022
+ }
8023
+ var view;
8024
+ try { view = await _buildAnalyticsView(win); }
8025
+ catch (e2) {
8026
+ win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
8027
+ notice = _safeNotice(e2, "analytics.view").message.replace(/^analytics:\s*/, "");
8028
+ view = await _buildAnalyticsView(win);
8029
+ }
8030
+ _sendHtml(res, 200, renderAdminAnalytics({
8031
+ shop_name: deps.shop_name, nav_available: navAvailable, view: view, notice: notice,
8032
+ }));
8033
+ },
8034
+ ));
7899
8035
  }
7900
8036
 
7901
8037
  // ---- subscriptions --------------------------------------------------
@@ -10831,6 +10967,121 @@ function _statCard(label, value, accent) {
10831
10967
  "<div class=\"value" + (accent ? " accent" : "") + "\">" + _htmlEscape(value) + "</div></div>";
10832
10968
  }
10833
10969
 
10970
+ // Format a money amount that may be negative. `pricing.format` (composing
10971
+ // b.money) asserts a non-negative minor-unit; analytics net revenue
10972
+ // subtracts refunds, so a refund-dominated window can go negative. Render
10973
+ // the magnitude through the same formatter and carry the sign by hand so
10974
+ // the screen never 500s on a legitimately-negative aggregate.
10975
+ function _fmtSignedMoney(amountMinor, currency) {
10976
+ var n = Number(amountMinor) || 0;
10977
+ if (n < 0) return "-" + pricing.format(-n, currency);
10978
+ return pricing.format(n, currency);
10979
+ }
10980
+
10981
+ // Product-performance + behaviour screen for /admin/analytics. Surfaces the
10982
+ // aggregates /admin/reports does NOT: units-ranked SKU performance, the
10983
+ // browse→buy funnel + conversion rate, most-viewed products, top search
10984
+ // terms, and a revenue-by-day trend sparkline. `opts.view` is the
10985
+ // `_buildAnalyticsView` payload (never null — the route always builds a
10986
+ // view, falling back to the default window on a bad range). Each table
10987
+ // renders an honest empty state when its aggregate is empty.
10988
+ function renderAdminAnalytics(opts) {
10989
+ opts = opts || {};
10990
+ var view = opts.view || {};
10991
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
10992
+ var cur = view.currency || "USD";
10993
+
10994
+ var fromVal = _dateInputValue(view.from);
10995
+ // `to` is exclusive (next UTC midnight after the operator's chosen end
10996
+ // day) — step back one day to render the inclusive end day into the input.
10997
+ var toVal = _dateInputValue((view.to || Date.now()) - b.constants.TIME.days(1));
10998
+
10999
+ // Date-range form (GET so the window lives in the URL — bookmarkable).
11000
+ var rangeForm =
11001
+ "<form method=\"get\" action=\"/admin/analytics\" class=\"order-filters\">" +
11002
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\"></label>" +
11003
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\"></label>" +
11004
+ "<button class=\"btn\" type=\"submit\">Apply</button>" +
11005
+ "<a class=\"btn btn--ghost\" href=\"/admin/reports\">Open sales report →</a>" +
11006
+ "</form>";
11007
+
11008
+ // ---- conversion funnel + rate (pre-purchase, from the event stream).
11009
+ var fn = view.funnel || { pdp_views: 0, cart_adds: 0, checkout_starts: 0, checkout_completes: 0, conversion_rate: 0 };
11010
+ var ratePct = (Number(fn.conversion_rate) || 0) * 100;
11011
+ var funnelStats =
11012
+ "<section><h2>Browse-to-buy funnel</h2>" +
11013
+ "<p class=\"meta\">Pre-purchase signal from the event stream — the sales report covers post-order status; this covers the path to it.</p>" +
11014
+ "<div class=\"stat-grid\">" +
11015
+ _statCard("Product views", String(fn.pdp_views || 0)) +
11016
+ _statCard("Cart adds", String(fn.cart_adds || 0)) +
11017
+ _statCard("Checkouts started", String(fn.checkout_starts || 0)) +
11018
+ _statCard("Checkouts completed", String(fn.checkout_completes || 0)) +
11019
+ _statCard("Conversion rate", ratePct.toFixed(2) + "%", true) +
11020
+ "</div>" +
11021
+ "</section>";
11022
+
11023
+ // ---- revenue-by-day trend sparkline (server-rendered SVG, same
11024
+ // mechanism the dashboard uses).
11025
+ var sparkBlock =
11026
+ "<section><h2>Revenue trend</h2><div class=\"panel\">" +
11027
+ _sparkSvg(view.by_day || [], cur) +
11028
+ "</div></section>";
11029
+
11030
+ // ---- top SKUs by units sold (reports ranks top products by revenue;
11031
+ // this ranks by units, the complementary view).
11032
+ var topSkus = view.top_skus || [];
11033
+ var skuRows = topSkus.length
11034
+ ? topSkus.map(function (r) {
11035
+ return "<tr><td>" + _htmlEscape(r.sku) + "</td>" +
11036
+ "<td class=\"num\">" + _htmlEscape(String(r.units_sold)) + "</td>" +
11037
+ "<td class=\"num\">" + _htmlEscape(_fmtSignedMoney(r.revenue_minor, r.currency)) + "</td></tr>";
11038
+ }).join("")
11039
+ : "<tr><td colspan=\"3\" class=\"empty\">No sales in this window.</td></tr>";
11040
+ var skuBlock =
11041
+ "<section><h2>Top SKUs by units sold</h2><div class=\"panel\">" +
11042
+ _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + skuRows + "</tbody></table>") +
11043
+ "</div></section>";
11044
+
11045
+ // ---- most-viewed products (event stream, pre-purchase).
11046
+ var viewed = view.top_viewed_products || [];
11047
+ var viewedRows = viewed.length
11048
+ ? viewed.map(function (r) {
11049
+ return "<tr><td>" + _htmlEscape(r.product_id) + "</td>" +
11050
+ "<td class=\"num\">" + _htmlEscape(String(r.count)) + "</td></tr>";
11051
+ }).join("")
11052
+ : "<tr><td colspan=\"2\" class=\"empty\">No product views in this window.</td></tr>";
11053
+
11054
+ // ---- top search terms (event stream, pre-purchase).
11055
+ var searches = view.top_search_terms || [];
11056
+ var searchRows = searches.length
11057
+ ? searches.map(function (r) {
11058
+ return "<tr><td>" + _htmlEscape(r.search_q) + "</td>" +
11059
+ "<td class=\"num\">" + _htmlEscape(String(r.count)) + "</td></tr>";
11060
+ }).join("")
11061
+ : "<tr><td colspan=\"2\" class=\"empty\">No searches in this window.</td></tr>";
11062
+
11063
+ var twoCol =
11064
+ "<section><h2>Demand signal</h2><div class=\"two-col\">" +
11065
+ "<div class=\"panel\">" +
11066
+ "<h3 class=\"subhead\">Most-viewed products</h3>" +
11067
+ _tableWrap("<table><thead><tr><th scope=\"col\">Product</th><th scope=\"col\" class=\"num\">Views</th></tr></thead><tbody>" + viewedRows + "</tbody></table>") +
11068
+ "</div>" +
11069
+ "<div class=\"panel\">" +
11070
+ "<h3 class=\"subhead\">Top search terms</h3>" +
11071
+ _tableWrap("<table><thead><tr><th scope=\"col\">Query</th><th scope=\"col\" class=\"num\">Searches</th></tr></thead><tbody>" + searchRows + "</tbody></table>") +
11072
+ "</div>" +
11073
+ "</div></section>";
11074
+
11075
+ var body =
11076
+ "<section><h2>Analytics</h2>" + notice +
11077
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(view.from)) + " → " + _htmlEscape(_fmtDate(view.to)) + "</p>" +
11078
+ "<p class=\"meta\">Product performance and pre-purchase behaviour. For order revenue totals, AOV, and refund rate see the <a href=\"/admin/reports\">sales report</a>.</p>" +
11079
+ rangeForm +
11080
+ "</section>" +
11081
+ funnelStats + sparkBlock + skuBlock + twoCol;
11082
+ return _renderAdminShell(opts.shop_name, "Analytics", body, "analytics", opts.nav_available);
11083
+ }
11084
+
10834
11085
  // ---- admin web pages (login / landing / setup wizard) -------------------
10835
11086
 
10836
11087
  // Console nav — one entry per HTML console screen. `active` highlights
@@ -10847,6 +11098,7 @@ var ADMIN_NAV_ITEMS = [
10847
11098
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
10848
11099
  { key: "orders", href: "/admin/orders", label: "Orders" },
10849
11100
  { key: "reports", href: "/admin/reports", label: "Reports" },
11101
+ { key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
10850
11102
  { key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
10851
11103
  { key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
10852
11104
  { key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
@@ -17662,6 +17914,7 @@ module.exports = {
17662
17914
  mount: mount,
17663
17915
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
17664
17916
  renderDashboard: renderDashboard,
17917
+ renderAdminAnalytics: renderAdminAnalytics,
17665
17918
  renderAdminLogin: renderAdminLogin,
17666
17919
  renderAdminLanding: renderAdminLanding,
17667
17920
  renderAdminSetup: renderAdminSetup,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.69",
2
+ "version": "0.3.71",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",