@blamejs/blamejs-shop 0.3.65 → 0.3.67
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 +4 -0
- package/README.md +1 -1
- package/lib/admin.js +128 -2
- package/lib/asset-manifest.json +3 -3
- package/lib/error-log.js +190 -0
- package/lib/search-ranking.js +8 -1
- package/lib/storefront.js +117 -12
- 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.67 (2026-06-05) — **Digital orders no longer require a shipping address, and search ranking shrugs off malformed weights.** A cart containing only digital goods completes checkout since 0.3.63, but the shipping form still demanded a street address and city the order would never use. For an all-digital cart, the form now marks the address fields optional with an honest note — email stays required for the receipt and country for tax — and the backend enforces exactly the same relaxed set, while any value the shopper does supply is still format-validated. Carts with any shippable item keep the full requirements. Separately, the search ranker now skips a non-numeric weight in a hand-edited weight set instead of letting it poison every score into NaN and garble the result order; the edge and container rankers apply the identical filter, keeping their orders locked together even on malformed configuration. **Fixed:** *Digital-only carts check out without a street address* — When no line in the cart requires shipping, the checkout form renders the address block as optional — the required markers move off street and city, a note explains that a digital order ships nothing and country is kept for tax, and the backend requires exactly email, name, and country. Values the shopper supplies anyway are still format-validated, including the per-field error rendering. A cart with any physical item keeps the full address requirements, and a missing variant record counts as physical, so the relaxation can never trigger on incomplete data. · *Search ranking ignores non-numeric weights instead of corrupting the order* — A weight set carrying a non-numeric value — possible through a hand-edited database row — multiplied into every product's score as NaN, making the result order effectively random on the container while the edge filtered the bad weight and produced a different order. Both rankers now skip non-numeric weights identically, so a malformed entry degrades to "that signal contributes nothing" with edge and container orders staying byte-identical.
|
|
12
|
+
|
|
13
|
+
- v0.3.66 (2026-06-05) — **Production server errors are readable from the admin console and the CLI.** When a server-side failure was scrubbed to a clean customer-facing page — a payment-processor rejection at checkout, a public-API failure, an admin-action error — the underlying detail was only visible in the hosting dashboard's container log viewer. The error-log primitive now captures those failures into the database: route, method, status, a length-bounded message, and a timestamp, ring-buffered to the newest five hundred entries, written fire-and-forget so a logging failure can never affect a customer request, and carrying no customer or session data. A new Errors screen in the admin console lists them newest-first, and the same route answers a bearer-token request with JSON, so operators and tooling can read production errors with one curl. A schema migration adds the message column and applies on deploy. **Added:** *Server-error capture with an admin Errors screen and JSON API* — Scrubbed 500-class failures — the checkout confirm step, the public catalog API, and admin console actions — now also record route, method, status, and a truncated error message into the error-log table (newest five hundred kept; writes are drop-silent and never awaited in the request path; no customer, session, or header data is stored). GET /admin/errors renders the newest entries in the console with every cell escaped, and the same path with an Authorization: Bearer admin token returns JSON for command-line and tooling access. Migration 0208 adds the message column plus an index and applies with the normal deploy.
|
|
14
|
+
|
|
11
15
|
- v0.3.65 (2026-06-04) — **Operator-tuned search ranking applies to edge-served visitors.** Search-ranking weight sets and manual pins applied only on container-served search — anonymous visitors, who are served from the edge cache and make up most traffic, got unranked results no matter what the operator tuned. The edge search path now applies the identical ranking: pinned products lead in their configured positions, the rest order by the active weight set, with byte-identical scoring pinned by a parity test in the same style as the existing faceting and synonym parity guards. Ranking and pin changes reach edge visitors within the same sixty-second cache window as every other operator-tunable edge surface. If the ranking tables are absent or unconfigured, edge search degrades to its previous unranked order rather than failing. **Fixed:** *Edge search results honor ranking weights and manual pins* — The /search route served from the edge ordered results by filter match alone, ignoring the active search weight set and any merchandiser pins, so ranking tuning was visible only to signed-in (container-served) visitors. The edge now loads the active weight set and pins on each cache fill and applies the same scoring as the container — pins first in position order, then weighted score descending, with identical tie-breaking. The two implementations are locked together by a parity test that runs the same fixtures through both. Changes the operator makes take effect for edge visitors within the standard sixty-second cache freshness window; a missing or unconfigured ranking table degrades to the unranked order, never an error.
|
|
12
16
|
|
|
13
17
|
- v0.3.64 (2026-06-04) — **Google Pay and Apple Pay express buttons can run on the payment page.** The app-level Permissions-Policy denies the Payment Request API everywhere — including the payment page, the one surface whose job is payment — so Stripe's express wallet buttons (Google Pay, Apple Pay) could not operate and the browser console filled with policy violations. The payment page now sends a route-scoped Permissions-Policy that grants the payment feature to the page itself, Stripe, and Google Pay, while every other feature stays denied and every other route keeps the fully strict header — the same route-scoping pattern the payment pages already use for their Content-Security-Policy. Card payments and 3-D Secure challenges were verified live before and after; a remaining cosmetic Trusted-Types console message from Stripe's loader is documented in code with the test recipe for future verification, and does not affect card, wallet, or 3-D Secure flows. **Fixed:** *Payment page grants the Payment Request API to Stripe and Google Pay* — The default Permissions-Policy sets payment=() sitewide, which blocks the Payment Request API that Stripe's express wallet buttons need. The /pay route now emits the same policy with exactly one change — payment=(self "https://js.stripe.com" "https://pay.google.com") — derived from the same source as the default header so the two can never drift apart. Camera, microphone, geolocation, and every other feature remain denied on the payment page, and all other routes keep payment=(). Tests pin the scoped header on the payment route, the strict header elsewhere, and that exactly one feature differs from the default.
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
96
96
|
| **`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). |
|
|
97
97
|
| **`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. |
|
|
98
98
|
| **`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. |
|
|
99
|
-
| **`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 + 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. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and
|
|
99
|
+
| **`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 + 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, 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
100
|
| **`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. |
|
|
101
101
|
| **`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. |
|
|
102
102
|
|
package/lib/admin.js
CHANGED
|
@@ -43,6 +43,15 @@ var b = require("./vendor/blamejs");
|
|
|
43
43
|
|
|
44
44
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
45
45
|
|
|
46
|
+
// Operator-readable error-log sink (lib/error-log.js), set by mount()
|
|
47
|
+
// when the deployment wires `deps.errorLog`. `_safeNotice` records the
|
|
48
|
+
// scrubbed message of a genuine 5xx here so the failure is reachable
|
|
49
|
+
// from /admin/errors + the admin JSON API, not just the framework
|
|
50
|
+
// audit sink. Module-level (like the `b.audit` _safeNotice already
|
|
51
|
+
// uses) because admin.mount runs once per process; null until wired,
|
|
52
|
+
// and every write is drop-silent / fire-and-forget.
|
|
53
|
+
var _errorLogSink = null;
|
|
54
|
+
|
|
46
55
|
// Per-request store for the double-submit CSRF token. The admin console is
|
|
47
56
|
// container-only and has no locale ALS (unlike the storefront), so it gets
|
|
48
57
|
// its own: a sync middleware in `mount()` seeds the request's `req.csrfToken`
|
|
@@ -444,11 +453,26 @@ function _safeNotice(e, auditAction) {
|
|
|
444
453
|
// Genuine unknown error — record it server-side via the framework audit
|
|
445
454
|
// (drop-silent) so operators can correlate, then hand back a generic
|
|
446
455
|
// message + a 500. The raw message never reaches the client.
|
|
456
|
+
var detail = msg || String(e);
|
|
447
457
|
b.audit.safeEmit({
|
|
448
458
|
action: AUDIT_NAMESPACE + "." + (auditAction || "request") + ".error",
|
|
449
459
|
outcome: "failure",
|
|
450
|
-
metadata: { message:
|
|
460
|
+
metadata: { message: detail },
|
|
451
461
|
});
|
|
462
|
+
// ALSO record into the operator-readable error log when wired, so an
|
|
463
|
+
// admin-console 500 surfaces at /admin/errors + the admin JSON API.
|
|
464
|
+
// The route is the audit action namespace (the handler path isn't
|
|
465
|
+
// threaded here); drop-silent + fire-and-forget, never blocks the
|
|
466
|
+
// response. captureServerError can't throw, but guard the dispatch.
|
|
467
|
+
if (_errorLogSink) {
|
|
468
|
+
try {
|
|
469
|
+
_errorLogSink.captureServerError({
|
|
470
|
+
route: "admin:" + (auditAction || "request"),
|
|
471
|
+
message: detail,
|
|
472
|
+
status: 500,
|
|
473
|
+
});
|
|
474
|
+
} catch (_capErr) { /* drop-silent — never let recording crash the catch */ }
|
|
475
|
+
}
|
|
452
476
|
return { status: 500, code: "internal-error", message: "Something went wrong — please try again." };
|
|
453
477
|
}
|
|
454
478
|
|
|
@@ -536,6 +560,12 @@ function mount(router, deps) {
|
|
|
536
560
|
// An operator who wants the console surface minimized can pass
|
|
537
561
|
// `auditLog: false` to hide both the route and the nav link.
|
|
538
562
|
var auditLog = deps.auditLog !== false;
|
|
563
|
+
// Operator-readable error feed at /admin/errors. The route + nav link
|
|
564
|
+
// mount only when the deployment wires `deps.errorLog` (the
|
|
565
|
+
// lib/error-log.js handle backed by D1). Also published to the
|
|
566
|
+
// module-level sink so `_safeNotice` records admin-console 500s here.
|
|
567
|
+
var errorLog = deps.errorLog || null;
|
|
568
|
+
_errorLogSink = errorLog;
|
|
539
569
|
|
|
540
570
|
// Which optional console sections are wired — gates their nav links so a
|
|
541
571
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -543,7 +573,7 @@ function mount(router, deps) {
|
|
|
543
573
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
544
574
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
545
575
|
// notice when the salesReports primitive isn't wired.
|
|
546
|
-
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, 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 };
|
|
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, 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 };
|
|
547
577
|
|
|
548
578
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
549
579
|
|
|
@@ -1838,6 +1868,55 @@ function mount(router, deps) {
|
|
|
1838
1868
|
));
|
|
1839
1869
|
}
|
|
1840
1870
|
|
|
1871
|
+
// ---- captured server errors (operator-readable error feed) ----------
|
|
1872
|
+
//
|
|
1873
|
+
// Surfaces the scrubbed messages of server-side 5xx-class failures
|
|
1874
|
+
// (checkout-confirm 500, the catalog-API scrub, the admin
|
|
1875
|
+
// unknown-error path) that lib/error-log.js captured into D1. The
|
|
1876
|
+
// problem this solves: those messages otherwise reach only the
|
|
1877
|
+
// container's LOCAL audit sink, which `wrangler tail` doesn't carry —
|
|
1878
|
+
// so an operator (or tooling) had no CLI/HTTP way to read production
|
|
1879
|
+
// errors. Both surfaces share one read:
|
|
1880
|
+
//
|
|
1881
|
+
// * `GET /admin/errors` with a bearer token → JSON
|
|
1882
|
+
// ({ rows, limit }) for CLI/tooling:
|
|
1883
|
+
// curl -H "Authorization: Bearer $ADMIN_API_KEY" https://…/admin/errors
|
|
1884
|
+
// * `GET /admin/errors` with the admin browser cookie → the HTML
|
|
1885
|
+
// console screen (newest-first, time + route + message, monospace,
|
|
1886
|
+
// escaped).
|
|
1887
|
+
//
|
|
1888
|
+
// Read-only observability view: any read failure degrades to an empty
|
|
1889
|
+
// table + notice, never a 500.
|
|
1890
|
+
if (errorLog) {
|
|
1891
|
+
function _errorsLimit(req) {
|
|
1892
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1893
|
+
var n = parseInt((url && url.searchParams.get("limit")) || "", 10);
|
|
1894
|
+
// Clamp into the primitive's [1, 500] read bound; default 100.
|
|
1895
|
+
if (!Number.isFinite(n) || n < 1) return 100;
|
|
1896
|
+
return n > 500 ? 500 : n;
|
|
1897
|
+
}
|
|
1898
|
+
router.get("/admin/errors", _pageOrApi(true,
|
|
1899
|
+
R(async function (req, res) { // bearer → JSON
|
|
1900
|
+
var limit = _errorsLimit(req);
|
|
1901
|
+
var rows = [];
|
|
1902
|
+
try { rows = await errorLog.recentServerErrors({ limit: limit }); }
|
|
1903
|
+
catch (_e) { rows = []; }
|
|
1904
|
+
_json(res, 200, { rows: rows, limit: limit });
|
|
1905
|
+
}),
|
|
1906
|
+
async function (req, res) { // browser cookie → HTML
|
|
1907
|
+
var limit = _errorsLimit(req);
|
|
1908
|
+
var rows = [];
|
|
1909
|
+
var notice = null;
|
|
1910
|
+
try { rows = await errorLog.recentServerErrors({ limit: limit }); }
|
|
1911
|
+
catch (_e) { rows = []; notice = "The error log is temporarily unavailable."; }
|
|
1912
|
+
_sendHtml(res, 200, renderAdminErrors({
|
|
1913
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1914
|
+
rows: rows, notice: notice,
|
|
1915
|
+
}));
|
|
1916
|
+
},
|
|
1917
|
+
));
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1841
1920
|
// ---- shipment tracking (operator-managed) ---------------------------
|
|
1842
1921
|
//
|
|
1843
1922
|
// Attach a shipment to an order (carrier + optional tracking number) and
|
|
@@ -10605,6 +10684,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
10605
10684
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
10606
10685
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
10607
10686
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
10687
|
+
{ key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
|
|
10608
10688
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
10609
10689
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
10610
10690
|
{ key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
|
|
@@ -11005,6 +11085,52 @@ function renderAdminAudit(opts) {
|
|
|
11005
11085
|
return _renderAdminShell(opts.shop_name, "Audit", body, "audit", opts.nav_available);
|
|
11006
11086
|
}
|
|
11007
11087
|
|
|
11088
|
+
// epoch-ms → "YYYY-MM-DD HH:MM:SS" (UTC). The error feed needs
|
|
11089
|
+
// to-the-second precision (multiple 500s a minute aren't unusual), so
|
|
11090
|
+
// this is finer-grained than `_fmtDate`. Guards a bad value to an em
|
|
11091
|
+
// dash so the template never throws.
|
|
11092
|
+
function _fmtTs(v) {
|
|
11093
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
11094
|
+
if (!isFinite(n)) return "—";
|
|
11095
|
+
return new Date(n).toISOString().slice(0, 19).replace("T", " ") + " UTC";
|
|
11096
|
+
}
|
|
11097
|
+
|
|
11098
|
+
// Captured-server-error feed — newest-first list of the scrubbed 5xx
|
|
11099
|
+
// messages lib/error-log.js recorded, so an operator reads production
|
|
11100
|
+
// failures from the console (and, via the bearer JSON contract, the
|
|
11101
|
+
// CLI) instead of only the container's local logs. Every cell is
|
|
11102
|
+
// escaped at the builder: `route` and `message` are operator/system
|
|
11103
|
+
// strings that could in principle carry markup, so a captured payload
|
|
11104
|
+
// is neutralized before it reaches the DOM (the message column escapes
|
|
11105
|
+
// THEN renders inside <code> — no truncation, the whole captured
|
|
11106
|
+
// message is the point of the feed).
|
|
11107
|
+
function renderAdminErrors(opts) {
|
|
11108
|
+
opts = opts || {};
|
|
11109
|
+
var rows = opts.rows || [];
|
|
11110
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11111
|
+
|
|
11112
|
+
var bodyRows = rows.map(function (row) {
|
|
11113
|
+
return "<tr>" +
|
|
11114
|
+
"<td>" + _htmlEscape(_fmtTs(row.occurred_at)) + "</td>" +
|
|
11115
|
+
"<td><code>" + _htmlEscape(String(row.status)) + "</code></td>" +
|
|
11116
|
+
"<td><code>" + _htmlEscape(String(row.method || "")) + "</code></td>" +
|
|
11117
|
+
"<td><code>" + _htmlEscape(String(row.route || "")) + "</code></td>" +
|
|
11118
|
+
"<td class=\"audit-meta\"><code>" + _htmlEscape(String(row.message == null ? "" : row.message)) + "</code></td>" +
|
|
11119
|
+
"</tr>";
|
|
11120
|
+
}).join("");
|
|
11121
|
+
|
|
11122
|
+
var table = rows.length
|
|
11123
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Time</th><th scope=\"col\">Status</th><th scope=\"col\">Method</th><th scope=\"col\">Route</th><th scope=\"col\">Message</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
|
|
11124
|
+
: "<p class=\"empty\">No server errors recorded.</p>";
|
|
11125
|
+
|
|
11126
|
+
var lede = "<p class=\"meta\">Server-side 5xx failures captured from the request lifecycle — newest first. " +
|
|
11127
|
+
"The same feed is available as JSON for tooling: " +
|
|
11128
|
+
"<code>GET /admin/errors</code> with an <code>Authorization: Bearer</code> token.</p>";
|
|
11129
|
+
|
|
11130
|
+
var body = "<section><h2>Errors</h2>" + notice + lede + table + "</section>";
|
|
11131
|
+
return _renderAdminShell(opts.shop_name, "Errors", body, "errors", opts.nav_available);
|
|
11132
|
+
}
|
|
11133
|
+
|
|
11008
11134
|
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
11009
11135
|
// guard so a bad value never throws inside the template.
|
|
11010
11136
|
function _dateInputValue(v) {
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
2
|
+
"version": "0.3.67",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
|
|
6
6
|
"fingerprinted": "css/admin.44eb97700c660798.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-fzlpYGSv30HHY+ir9ym4VxwwgwzXQ9Sn153QMXLVKOPJ02eDNO/cI6Ww3rPluQ8R",
|
|
10
|
+
"fingerprinted": "css/main.15dfd64f4536314d.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/error-log.js
CHANGED
|
@@ -58,6 +58,29 @@
|
|
|
58
58
|
* errorLog.metrics({ from, to })
|
|
59
59
|
* → { total, by_status_class, p50_ms, p95_ms, p99_ms }
|
|
60
60
|
*
|
|
61
|
+
* Captured-error surface (the operator-readable error feed):
|
|
62
|
+
*
|
|
63
|
+
* errorLog.captureServerError({ route, message, status?, method?, occurred_at? })
|
|
64
|
+
* → { id, occurred_at } on success
|
|
65
|
+
* → { dropped: true, reason } on bad input / write failure (no throw)
|
|
66
|
+
*
|
|
67
|
+
* errorLog.recentServerErrors({ limit? })
|
|
68
|
+
* → [{ id, status, route, method, message, occurred_at }]
|
|
69
|
+
* newest-first
|
|
70
|
+
*
|
|
71
|
+
* `captureServerError` records the scrubbed MESSAGE of a server-side
|
|
72
|
+
* 5xx-class failure (the detail a catch would otherwise lose to the
|
|
73
|
+
* container's local audit sink) into the `error_log` table's
|
|
74
|
+
* `message` column. It is drop-silent on bad input AND on write
|
|
75
|
+
* failure — wired into request catch blocks, it must never turn one
|
|
76
|
+
* failed request into two. The feed is a ring buffer: each capture
|
|
77
|
+
* trims to the newest SERVER_ERROR_CAP message-bearing rows. The
|
|
78
|
+
* message is length-bounded (truncated to MAX_MESSAGE, not dropped —
|
|
79
|
+
* the head of a failure is the useful part). `recentServerErrors`
|
|
80
|
+
* (read-side, THROWS on bad input) backs the admin console screen +
|
|
81
|
+
* the admin JSON API so operators read production errors from the
|
|
82
|
+
* CLI.
|
|
83
|
+
*
|
|
61
84
|
* Percentile math: response_time_ms is collected, sorted, and a
|
|
62
85
|
* simple index-into-sorted-array is read off (no streaming
|
|
63
86
|
* estimator — operators on a single-shard D1 dataset hit modest
|
|
@@ -87,6 +110,19 @@ var MAX_SESSION_ID = 512;
|
|
|
87
110
|
var MAX_CUSTOMER_ID = 512;
|
|
88
111
|
var MAX_ERROR_ID = 128;
|
|
89
112
|
|
|
113
|
+
// Captured server-error message bound. Unlike `path` (where an
|
|
114
|
+
// over-long value is a caller bug and drops), the message is the whole
|
|
115
|
+
// reason the row exists — a long stack/PSP string is TRUNCATED to this
|
|
116
|
+
// bound rather than dropped, so the operator still sees the head of the
|
|
117
|
+
// failure. The tail is the least useful part of a scrubbed message.
|
|
118
|
+
var MAX_MESSAGE = 1024;
|
|
119
|
+
|
|
120
|
+
// Ring-buffer cap on the captured-server-error feed. Each capture
|
|
121
|
+
// trims rows beyond the newest N (message-bearing rows only — the
|
|
122
|
+
// high-volume response-metadata rows are untouched), so the feed is
|
|
123
|
+
// self-trimming and never grows unbounded on a single-shard D1.
|
|
124
|
+
var SERVER_ERROR_CAP = 500;
|
|
125
|
+
|
|
90
126
|
// ---- read-side validators (THROW — dashboard queries) ------------------
|
|
91
127
|
|
|
92
128
|
function _epochMs(n, label) {
|
|
@@ -252,6 +288,158 @@ function create(opts) {
|
|
|
252
288
|
}
|
|
253
289
|
},
|
|
254
290
|
|
|
291
|
+
// Capture a server-side 5xx-class failure's MESSAGE for the
|
|
292
|
+
// operator console + the admin JSON API. **Drop-silent on bad
|
|
293
|
+
// input AND on write failure — by design.** This is wired into the
|
|
294
|
+
// catch blocks of failing requests (checkout confirm, the public
|
|
295
|
+
// API 500 scrub, the admin unknown-error path); an error-log write
|
|
296
|
+
// that threw would turn one failed request into two, so every
|
|
297
|
+
// refusal resolves to a silent drop.
|
|
298
|
+
//
|
|
299
|
+
// The captured row reuses the `error_log` table with the `message`
|
|
300
|
+
// column set — that's what distinguishes a captured-error row from
|
|
301
|
+
// a response-metadata row. Only the route + method + status +
|
|
302
|
+
// message + timestamp are stored; NO session/customer/referrer/UA
|
|
303
|
+
// PII rides this path (the message is operator-supplied and already
|
|
304
|
+
// scrubbed by the caller — this primitive bounds its length, it
|
|
305
|
+
// does not re-scrub content).
|
|
306
|
+
//
|
|
307
|
+
// captureServerError({
|
|
308
|
+
// route, // required — request path, query stripped
|
|
309
|
+
// message, // required — scrubbed error text (truncated to MAX_MESSAGE)
|
|
310
|
+
// status?, // optional — defaults to 500; clamped to [500, 599]
|
|
311
|
+
// method?, // optional — defaults to "GET"; must be a known method
|
|
312
|
+
// occurred_at?, // optional — defaults to Date.now()
|
|
313
|
+
// })
|
|
314
|
+
// → { id, occurred_at } on success
|
|
315
|
+
// → { dropped: true, reason } on bad input / write failure (no throw)
|
|
316
|
+
//
|
|
317
|
+
// After a successful insert the feed is trimmed to the newest
|
|
318
|
+
// SERVER_ERROR_CAP message-bearing rows (the ring buffer). The trim
|
|
319
|
+
// is best-effort: a trim failure never propagates (the row already
|
|
320
|
+
// landed; an over-cap feed self-corrects on the next capture).
|
|
321
|
+
captureServerError: async function (input) {
|
|
322
|
+
try {
|
|
323
|
+
if (!input || typeof input !== "object") {
|
|
324
|
+
return { dropped: true, reason: "input must be an object" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// route — required, bounded string (query string stripped by
|
|
328
|
+
// the caller, same contract as recordError's `path`).
|
|
329
|
+
var route = input.route;
|
|
330
|
+
if (!_isBoundedString(route, MAX_PATH)) {
|
|
331
|
+
return { dropped: true, reason: "route must be a non-empty string ≤ " + MAX_PATH + " chars" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// message — required, non-empty. Over-long is TRUNCATED (the
|
|
335
|
+
// head of a scrubbed failure is the useful part), not dropped.
|
|
336
|
+
var rawMessage = input.message;
|
|
337
|
+
if (typeof rawMessage !== "string" || rawMessage.length === 0) {
|
|
338
|
+
return { dropped: true, reason: "message must be a non-empty string" };
|
|
339
|
+
}
|
|
340
|
+
var message = rawMessage.length > MAX_MESSAGE
|
|
341
|
+
? rawMessage.slice(0, MAX_MESSAGE)
|
|
342
|
+
: rawMessage;
|
|
343
|
+
|
|
344
|
+
// status — optional, defaults to 500. Clamp into the 5xx band:
|
|
345
|
+
// this surface is for server-side failures, and a caller that
|
|
346
|
+
// hands a 4xx is misclassifying (the 4xx observability rows go
|
|
347
|
+
// through recordError, not here).
|
|
348
|
+
var status;
|
|
349
|
+
if (input.status == null) {
|
|
350
|
+
status = 500;
|
|
351
|
+
} else if (_isInt(input.status) && input.status >= 500 && input.status <= 599) {
|
|
352
|
+
status = input.status;
|
|
353
|
+
} else {
|
|
354
|
+
return { dropped: true, reason: "status must be an integer in [500, 599] when provided" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// method — optional, defaults to GET, must be a known method.
|
|
358
|
+
var method;
|
|
359
|
+
if (input.method == null) {
|
|
360
|
+
method = "GET";
|
|
361
|
+
} else if (typeof input.method === "string" && METHODS.indexOf(input.method) !== -1) {
|
|
362
|
+
method = input.method;
|
|
363
|
+
} else {
|
|
364
|
+
return { dropped: true, reason: "method must be one of " + METHODS.join(", ") + " when provided" };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// occurred_at — optional, defaults to Date.now().
|
|
368
|
+
var occurredAt;
|
|
369
|
+
if (input.occurred_at == null) {
|
|
370
|
+
occurredAt = Date.now();
|
|
371
|
+
} else if (_isNonNegative(input.occurred_at)) {
|
|
372
|
+
occurredAt = input.occurred_at;
|
|
373
|
+
} else {
|
|
374
|
+
return { dropped: true, reason: "occurred_at must be a non-negative integer when provided" };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// user_agent_class is NOT NULL on the table — captured errors
|
|
378
|
+
// carry no UA classification (the catch has no UA context worth
|
|
379
|
+
// persisting), so they land under the neutral "other" bucket.
|
|
380
|
+
var id = b.uuid.v7();
|
|
381
|
+
await query(
|
|
382
|
+
"INSERT INTO error_log " +
|
|
383
|
+
"(id, status, path, method, user_agent_class, message, occurred_at) " +
|
|
384
|
+
"VALUES (?1, ?2, ?3, ?4, 'other', ?5, ?6)",
|
|
385
|
+
[id, status, route, method, message, occurredAt],
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Ring-buffer trim — keep the newest SERVER_ERROR_CAP captured
|
|
389
|
+
// rows, delete the rest. Scoped to message-bearing rows so the
|
|
390
|
+
// response-metadata feed is never touched. Best-effort: a trim
|
|
391
|
+
// failure leaves an over-cap feed that the next capture corrects.
|
|
392
|
+
try {
|
|
393
|
+
await query(
|
|
394
|
+
"DELETE FROM error_log " +
|
|
395
|
+
" WHERE message IS NOT NULL " +
|
|
396
|
+
" AND id NOT IN ( " +
|
|
397
|
+
" SELECT id FROM error_log " +
|
|
398
|
+
" WHERE message IS NOT NULL " +
|
|
399
|
+
" ORDER BY occurred_at DESC, id DESC " +
|
|
400
|
+
" LIMIT ?1 " +
|
|
401
|
+
" )",
|
|
402
|
+
[SERVER_ERROR_CAP],
|
|
403
|
+
);
|
|
404
|
+
} catch (_trimErr) { /* best-effort — feed self-corrects next capture */ }
|
|
405
|
+
|
|
406
|
+
return { id: id, occurred_at: occurredAt };
|
|
407
|
+
} catch (_e) {
|
|
408
|
+
// Drop-silent on any unexpected failure — an error-log write
|
|
409
|
+
// mid-request must never crash the response it observes.
|
|
410
|
+
return { dropped: true, reason: "internal" };
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
// Newest-first page of captured server errors for the operator
|
|
415
|
+
// console + the admin JSON API. Read-side surface — THROWS on bad
|
|
416
|
+
// input (an operator's on the other end of the stack trace). Only
|
|
417
|
+
// the message-bearing rows are returned; the response-metadata rows
|
|
418
|
+
// recordError writes never surface here.
|
|
419
|
+
recentServerErrors: async function (opts) {
|
|
420
|
+
opts = opts || {};
|
|
421
|
+
var limit = opts.limit == null ? 100 : opts.limit;
|
|
422
|
+
_limit(limit, "limit", 500);
|
|
423
|
+
var r = await query(
|
|
424
|
+
"SELECT id, status, path, method, message, occurred_at " +
|
|
425
|
+
" FROM error_log " +
|
|
426
|
+
" WHERE message IS NOT NULL " +
|
|
427
|
+
" ORDER BY occurred_at DESC, id DESC " +
|
|
428
|
+
" LIMIT ?1",
|
|
429
|
+
[limit],
|
|
430
|
+
);
|
|
431
|
+
return r.rows.map(function (row) {
|
|
432
|
+
return {
|
|
433
|
+
id: row.id,
|
|
434
|
+
status: Number(row.status) || 0,
|
|
435
|
+
route: row.path,
|
|
436
|
+
method: row.method,
|
|
437
|
+
message: row.message,
|
|
438
|
+
occurred_at: Number(row.occurred_at) || 0,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
|
|
255
443
|
// Top-N 404 paths within the window. GROUP BY path, ORDER BY
|
|
256
444
|
// count DESC. The aggregate strips query strings at the write
|
|
257
445
|
// site so a single dead link surfaces as one row regardless of
|
|
@@ -518,4 +706,6 @@ module.exports = {
|
|
|
518
706
|
ONE_YEAR_MS: ONE_YEAR_MS,
|
|
519
707
|
DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
|
|
520
708
|
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
709
|
+
MAX_MESSAGE: MAX_MESSAGE,
|
|
710
|
+
SERVER_ERROR_CAP: SERVER_ERROR_CAP,
|
|
521
711
|
};
|
package/lib/search-ranking.js
CHANGED
|
@@ -401,7 +401,14 @@ function create(opts) {
|
|
|
401
401
|
if (typeof raw === "boolean") n = raw ? 1 : 0;
|
|
402
402
|
else if (typeof raw === "number" && isFinite(raw)) n = raw;
|
|
403
403
|
else continue;
|
|
404
|
-
|
|
404
|
+
// Defensive: a hand-edited weights_json row can carry a non-numeric
|
|
405
|
+
// weight; multiplying by it would NaN-poison the score and garble
|
|
406
|
+
// the sort. Skip it — the edge mirror (worker/data/search-ranking.js)
|
|
407
|
+
// applies the same filter, keeping the two rankers order-identical
|
|
408
|
+
// on malformed config.
|
|
409
|
+
var w = weights[k];
|
|
410
|
+
if (typeof w !== "number" || !isFinite(w)) continue;
|
|
411
|
+
score += w * n;
|
|
405
412
|
}
|
|
406
413
|
return score;
|
|
407
414
|
}
|
package/lib/storefront.js
CHANGED
|
@@ -6412,12 +6412,19 @@ var CART_LINE_EDITABLE =
|
|
|
6412
6412
|
// `p` pre-fills each field (from a signed-in customer's default shipping
|
|
6413
6413
|
// address, or the shopper's own typed values on a validation re-render);
|
|
6414
6414
|
// every value is escaped by `_addrField` / the email builder.
|
|
6415
|
-
//
|
|
6416
|
-
//
|
|
6417
|
-
//
|
|
6418
|
-
//
|
|
6415
|
+
//
|
|
6416
|
+
// `allDigital` flips the address block to optional: when every cart line
|
|
6417
|
+
// is a no-shipment good (variant requires_shipping = 0), street line 1 +
|
|
6418
|
+
// city drop their `required` attr + `*` marker and a short note explains
|
|
6419
|
+
// the order needs no shipping address. Country stays required because the
|
|
6420
|
+
// tax quote keys off it (checkout._shipTo validates a country on every
|
|
6421
|
+
// quote, digital included). For a cart with ANY shippable line, line1 +
|
|
6422
|
+
// city stay required for the common physical-goods path. The POST handler
|
|
6423
|
+
// enforces the SAME contract server-side via _requireCheckoutFields with
|
|
6424
|
+
// the same flag (backend validates, frontend displays). The service tier
|
|
6425
|
+
// (checkout._shipTo) stays presence-optional regardless so a non-form
|
|
6419
6426
|
// caller can still complete a digital-only order with a bare country.
|
|
6420
|
-
function _checkoutShippingFields(p, inv) {
|
|
6427
|
+
function _checkoutShippingFields(p, inv, allDigital) {
|
|
6421
6428
|
p = p || {};
|
|
6422
6429
|
var esc = b.template.escapeHtml;
|
|
6423
6430
|
// Merge the per-field invalid marker (a { field, message } from
|
|
@@ -6439,12 +6446,20 @@ function _checkoutShippingFields(p, inv) {
|
|
|
6439
6446
|
_fieldAriaAttr("co-err-", "email", inv) + ">" +
|
|
6440
6447
|
_fieldErrorSpan("co-err-", "email", inv) +
|
|
6441
6448
|
"</label>";
|
|
6449
|
+
// For an all-digital cart the address is optional — line1 + city lose
|
|
6450
|
+
// the `required` attr/marker and a note says so. A mixed/physical cart
|
|
6451
|
+
// keeps them required (the un-flagged default).
|
|
6452
|
+
var addrRequired = !allDigital;
|
|
6453
|
+
var digitalNote = allDigital
|
|
6454
|
+
? "<p class=\"checkout-page__digital-note\">This is a digital order — no shipping address needed. Just confirm your country below for tax.</p>"
|
|
6455
|
+
: "";
|
|
6442
6456
|
return email +
|
|
6443
6457
|
_addrField("name", "Full name", p.name, _mark("name", { required: true, maxlength: 120, autocomplete: "name" })) +
|
|
6444
|
-
|
|
6458
|
+
digitalNote +
|
|
6459
|
+
_addrField("line1", "Street address", p.line1, _mark("line1", { required: addrRequired, maxlength: 200, autocomplete: "address-line1" })) +
|
|
6445
6460
|
_addrField("line2", "Apt / suite / unit (optional)", p.line2, _mark("line2", { maxlength: 200, autocomplete: "address-line2" })) +
|
|
6446
6461
|
"<div class=\"form-row form-row--inline\">" +
|
|
6447
|
-
_addrField("city", "City", p.city, _mark("city", { required:
|
|
6462
|
+
_addrField("city", "City", p.city, _mark("city", { required: addrRequired, maxlength: 120, autocomplete: "address-level2" })) +
|
|
6448
6463
|
_addrField("state", "State / province code", p.state, _mark("state", { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" })) +
|
|
6449
6464
|
"</div>" +
|
|
6450
6465
|
"<div class=\"form-row form-row--inline\">" +
|
|
@@ -6483,13 +6498,23 @@ function _shipToFromBody(body) {
|
|
|
6483
6498
|
// _checkoutFieldFromError maps each to its input. The PayPal create route
|
|
6484
6499
|
// shares _shipToFromBody but not this gate — its format errors surface
|
|
6485
6500
|
// through the same service-tier validators.
|
|
6486
|
-
|
|
6501
|
+
//
|
|
6502
|
+
// `allDigital` (every cart line is a no-shipment good) drops the line1 +
|
|
6503
|
+
// city + region/postal presence gates: the order ships nothing, so only
|
|
6504
|
+
// email + name + a country (for the tax quote) are required. The country
|
|
6505
|
+
// itself is still validated for shape downstream by checkout._shipTo. Any
|
|
6506
|
+
// region/postal values the shopper DID supply still get format-validated
|
|
6507
|
+
// there — relaxing presence never relaxes format. A cart with ANY
|
|
6508
|
+
// shippable line keeps the full requirement set (the un-flagged default),
|
|
6509
|
+
// matching the form the GET path renders.
|
|
6510
|
+
function _requireCheckoutFields(body, shipTo, allDigital) {
|
|
6487
6511
|
if (!body.email || !String(body.email).trim()) {
|
|
6488
6512
|
throw new TypeError("checkout: customer.email — Enter your email address.");
|
|
6489
6513
|
}
|
|
6490
6514
|
if (!body.name || !String(body.name).trim()) {
|
|
6491
6515
|
throw new TypeError("checkout: customer.name — Enter the recipient's full name.");
|
|
6492
6516
|
}
|
|
6517
|
+
if (allDigital) return; // digital order — no shipping address required (country shape is validated downstream)
|
|
6493
6518
|
if (!shipTo.line1) throw new TypeError("checkout: ship_to.line1 — Enter a street address.");
|
|
6494
6519
|
if (!shipTo.city) throw new TypeError("checkout: ship_to.city — Enter a city.");
|
|
6495
6520
|
if (shipTo.country === "US" || shipTo.country === "CA") {
|
|
@@ -6712,7 +6737,7 @@ function renderCheckoutForm(opts) {
|
|
|
6712
6737
|
body = _spliceRaw(body, "RAW_TOTALS_ROWS", totalsRows);
|
|
6713
6738
|
body = _spliceRaw(body, "RAW_SUMMARY_NOTE", summaryNote);
|
|
6714
6739
|
body = _spliceRaw(body, "RAW_INLINE_ERROR", inlineError);
|
|
6715
|
-
body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field));
|
|
6740
|
+
body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field, !!opts.all_digital));
|
|
6716
6741
|
body = _spliceRaw(body, "RAW_SUMMARY_LINES", summaryLines);
|
|
6717
6742
|
// Pick-up-in-store picker, gift personalization, loyalty redeem, and the
|
|
6718
6743
|
// CAPTCHA widget are spliced in ABOVE the submit action row — inside the
|
|
@@ -9775,6 +9800,15 @@ function mount(router, deps) {
|
|
|
9775
9800
|
// launch flow converts the reservation into a Stripe-gated order).
|
|
9776
9801
|
var preorder = deps.preorder || null;
|
|
9777
9802
|
|
|
9803
|
+
// Operator-readable error log (lib/error-log.js). When wired, the
|
|
9804
|
+
// server-side catches below ALSO record their scrubbed failure
|
|
9805
|
+
// message here so it's reachable from the admin console + JSON API,
|
|
9806
|
+
// not just the container-local audit sink. Optional — absent it, the
|
|
9807
|
+
// catches behave exactly as before (audit-emit only). Every call is
|
|
9808
|
+
// drop-silent + fire-and-forget: captureServerError can never throw,
|
|
9809
|
+
// and a failing response must not wait on (or be undone by) the write.
|
|
9810
|
+
var errorLog = deps.errorLog || null;
|
|
9811
|
+
|
|
9778
9812
|
// CAPTCHA gate (bot challenge at signup / login / checkout). Active ONLY
|
|
9779
9813
|
// when the operator has registered a provider AND set CAPTCHA_PROVIDER_SLUG
|
|
9780
9814
|
// (server.js resolves the provider row at boot into captchaKind +
|
|
@@ -12068,6 +12102,35 @@ function mount(router, deps) {
|
|
|
12068
12102
|
}
|
|
12069
12103
|
|
|
12070
12104
|
if (deps.checkout && deps.order) {
|
|
12105
|
+
// True when EVERY cart line is a no-shipment good (its variant's
|
|
12106
|
+
// requires_shipping is 0) — the cart ships nothing, so the checkout
|
|
12107
|
+
// form drops the address requirement and the POST gate requires only
|
|
12108
|
+
// email + name + country (for the tax quote). A missing variant row,
|
|
12109
|
+
// a null/absent column, or an empty cart all read as "needs an
|
|
12110
|
+
// address" (shippable) — relaxing the gate is the exception, never the
|
|
12111
|
+
// default, so a lookup gap can't hide a required field. Mirrors
|
|
12112
|
+
// checkout._buildQuote's per-line `!!v.requires_shipping` enrichment.
|
|
12113
|
+
// The GET render path computes this inline from the variant rows it
|
|
12114
|
+
// already fetches for the order-summary lookup (no second query pass);
|
|
12115
|
+
// the POST gate has no such lookup, so it resolves here.
|
|
12116
|
+
async function _cartAllDigital(lines) {
|
|
12117
|
+
if (!lines || !lines.length) return false;
|
|
12118
|
+
if (!deps.catalog || !deps.catalog.variants || typeof deps.catalog.variants.get !== "function") {
|
|
12119
|
+
return false; // can't prove all-digital → keep the address gate
|
|
12120
|
+
}
|
|
12121
|
+
var shipByVariant = {};
|
|
12122
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
12123
|
+
var vid = lines[i].variant_id;
|
|
12124
|
+
if (shipByVariant[vid] !== undefined) continue;
|
|
12125
|
+
var v = await deps.catalog.variants.get(vid);
|
|
12126
|
+
shipByVariant[vid] = !v || v.requires_shipping == null || Number(v.requires_shipping) === 1;
|
|
12127
|
+
}
|
|
12128
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
12129
|
+
if (shipByVariant[lines[j].variant_id] !== false) return false; // a shippable line
|
|
12130
|
+
}
|
|
12131
|
+
return true;
|
|
12132
|
+
}
|
|
12133
|
+
|
|
12071
12134
|
// Build the renderCheckoutForm() opts for an active cart `c`: repriced
|
|
12072
12135
|
// lines, totals, the thumbnail lookup, and the signed-in customer's
|
|
12073
12136
|
// loyalty balance + prefill. Shared by the GET handler and the POST
|
|
@@ -12088,16 +12151,41 @@ function mount(router, deps) {
|
|
|
12088
12151
|
var totals = totalsDetail.totals;
|
|
12089
12152
|
// variant_id → { product, hero_media } lookup for the summary
|
|
12090
12153
|
// thumbnails + titles — same shape (and caching) the cart route uses.
|
|
12154
|
+
// The SAME variant row (catalog returns SELECT * — requires_shipping
|
|
12155
|
+
// is on it) feeds the all-digital determination below, so the form
|
|
12156
|
+
// can drop the address requirement on a no-shipment cart without a
|
|
12157
|
+
// second query pass. `shipBySku` caches requires_shipping per variant
|
|
12158
|
+
// so a duplicate cart line never re-fetches. A line whose variant row
|
|
12159
|
+
// is missing is treated as shippable (the never-relax-on-missing-data
|
|
12160
|
+
// stance), so a lookup gap can never accidentally hide a required
|
|
12161
|
+
// address.
|
|
12091
12162
|
var checkoutLookup = {};
|
|
12163
|
+
var shipByVariant = {};
|
|
12092
12164
|
for (var li = 0; li < lines.length; li += 1) {
|
|
12093
12165
|
var lvId = lines[li].variant_id;
|
|
12094
|
-
if (checkoutLookup[lvId]) continue;
|
|
12166
|
+
if (checkoutLookup[lvId] !== undefined) continue;
|
|
12095
12167
|
var lv = await deps.catalog.variants.get(lvId);
|
|
12096
|
-
if (!lv) {
|
|
12168
|
+
if (!lv) {
|
|
12169
|
+
checkoutLookup[lvId] = null;
|
|
12170
|
+
shipByVariant[lvId] = true; // missing variant → treat as shippable
|
|
12171
|
+
continue;
|
|
12172
|
+
}
|
|
12097
12173
|
var lprod = await deps.catalog.products.get(lv.product_id);
|
|
12098
12174
|
var lmedia = await deps.catalog.media.listForProduct(lv.product_id);
|
|
12099
12175
|
checkoutLookup[lvId] = { product: lprod, hero_media: lmedia.length ? lmedia[0] : null };
|
|
12176
|
+
// requires_shipping defaults to 1 (shippable) when the column is
|
|
12177
|
+
// absent — mirrors checkout._buildQuote's `!!v.requires_shipping`
|
|
12178
|
+
// with a null-safe default of shippable.
|
|
12179
|
+
shipByVariant[lvId] = lv.requires_shipping == null || Number(lv.requires_shipping) === 1;
|
|
12180
|
+
}
|
|
12181
|
+
// All-digital = a non-empty cart where NO line needs shipping. The
|
|
12182
|
+
// service tier already completes such an order (checkout's
|
|
12183
|
+
// digital_none fallback); this lets the form drop the address gate.
|
|
12184
|
+
var anyShippable = false;
|
|
12185
|
+
for (var si = 0; si < lines.length; si += 1) {
|
|
12186
|
+
if (shipByVariant[lines[si].variant_id] !== false) { anyShippable = true; break; }
|
|
12100
12187
|
}
|
|
12188
|
+
var allDigital = lines.length > 0 && !anyShippable;
|
|
12101
12189
|
// A signed-in customer drives two best-effort lookups, both keyed
|
|
12102
12190
|
// off the same auth env: the loyalty balance (for the redeem field)
|
|
12103
12191
|
// and the default shipping address (to pre-fill the form). Either
|
|
@@ -12161,6 +12249,9 @@ function mount(router, deps) {
|
|
|
12161
12249
|
lines: lines, totals: totals, totals_detail: totalsDetail,
|
|
12162
12250
|
shop_name: shopName, theme: theme,
|
|
12163
12251
|
product_lookup: checkoutLookup,
|
|
12252
|
+
// Every cart line is a no-shipment good → the form drops the
|
|
12253
|
+
// address requirement (and the POST gate mirrors the flag).
|
|
12254
|
+
all_digital: allDigital,
|
|
12164
12255
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
12165
12256
|
loyalty_balance: loyaltyBalance,
|
|
12166
12257
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
@@ -12325,7 +12416,13 @@ function mount(router, deps) {
|
|
|
12325
12416
|
}
|
|
12326
12417
|
var shipTo = _shipToFromBody(body);
|
|
12327
12418
|
try {
|
|
12328
|
-
|
|
12419
|
+
// A cart whose every line is a no-shipment good needs no shipping
|
|
12420
|
+
// address — only email + name + a country for the tax quote. The
|
|
12421
|
+
// form rendered the address block optional (all_digital); the gate
|
|
12422
|
+
// here mirrors that so the two never disagree. A mixed/physical
|
|
12423
|
+
// cart keeps the full requirement set.
|
|
12424
|
+
var coAllDigital = await _cartAllDigital(await deps.cart.listLines(c.id));
|
|
12425
|
+
_requireCheckoutFields(body, shipTo, coAllDigital);
|
|
12329
12426
|
// default_shipping_id may be a literal string or an
|
|
12330
12427
|
// operator-supplied async resolver (e.g. backed by the
|
|
12331
12428
|
// config primitive) so re-reads happen per request without
|
|
@@ -12437,6 +12534,14 @@ function mount(router, deps) {
|
|
|
12437
12534
|
outcome: "failure",
|
|
12438
12535
|
metadata: { message: msg },
|
|
12439
12536
|
});
|
|
12537
|
+
// ALSO record into the operator-readable error log so this
|
|
12538
|
+
// 500's detail is reachable from /admin/errors + the admin
|
|
12539
|
+
// JSON API, not just the container-local audit sink. Drop-
|
|
12540
|
+
// silent + fire-and-forget — never blocks or breaks the
|
|
12541
|
+
// styled error page the customer gets.
|
|
12542
|
+
if (errorLog) {
|
|
12543
|
+
errorLog.captureServerError({ route: "/checkout", message: msg, method: "POST", status: 500 });
|
|
12544
|
+
}
|
|
12440
12545
|
}
|
|
12441
12546
|
return _send(res, clientErr ? 400 : 500, renderCheckoutError({
|
|
12442
12547
|
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
package/package.json
CHANGED