@blamejs/blamejs-shop 0.3.49 → 0.3.51
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/lib/asset-manifest.json +17 -5
- package/lib/locale-router.js +39 -0
- package/lib/security-middleware.js +170 -7
- package/lib/storefront.js +583 -90
- 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.51 (2026-06-02) — **Payment scripts load under the strict CSP, an optional CAPTCHA gate guards signup and checkout, HSTS is preload-strength, and order actions are rate-limited.** The strict content-security-policy blocked the Stripe and PayPal scripts on the payment pages, so checkout would have failed even once payment keys were configured. Those pages now load the processor scripts through a route-scoped policy and same-origin script files, without relaxing the site-wide policy anywhere else. A CAPTCHA challenge can now be required on signup and checkout (and optionally login) once an operator configures a provider; it stays off until then. The edge now sends a two-year HSTS max-age so the domain qualifies for the preload list, and the order cancel, rate, and reorder actions are covered by the tighter per-route rate limit. **Added:** *Optional CAPTCHA gate on signup, checkout, and login* — When an operator configures a CAPTCHA provider (Cloudflare Turnstile, hCaptcha, or reCAPTCHA), a challenge is required on signup and checkout, with login behind an opt-in flag since passkey sign-in is already phishing-resistant. The token is verified server-side before the action proceeds. With no provider configured the gate is a no-op and every flow behaves exactly as before. **Changed:** *Preload-strength HSTS at the edge* — The edge now sends an HSTS max-age of two years (with includeSubDomains and preload), matching the container and meeting the one-year minimum required for HSTS preload-list inclusion. **Fixed:** *Payment pages load Stripe and PayPal under the strict CSP* — The pay and checkout pages now load the Stripe and PayPal scripts through a content-security-policy scoped to those routes plus same-origin script files, instead of inline script that the site-wide strict policy blocked. Checkout works once payment keys are set, and the site-wide policy — no inline script, Trusted Types required, restricted object and frame sources — is unchanged everywhere else. · *Order actions covered by the tighter rate limit* — Cancelling, rating, and reordering an order are now under the same strict per-route rate limit as other sensitive actions, rather than only the looser global limit.
|
|
12
|
+
|
|
13
|
+
- v0.3.50 (2026-06-02) — **Installable PWA manifest, hreflang for multilingual stores, complete sitemap and robots coverage, localized edge pages, and product Q&A rich results.** Several search-engine and internationalization signals were missing or inconsistent between the cached (edge) and dynamically rendered pages. This release serves an installable web-app manifest and a service worker, emits hreflang alternates when a store runs path- or subdomain-based locales, localizes the blog, CMS, and policy pages on the cached path (they were always English), brings the robots.txt and sitemap into agreement across both render paths with collections, categories, and CMS pages now included, and emits Q&A structured data on product pages. It also fixes a bug that dropped every product from the dynamically rendered sitemap. **Added:** *Installable web-app manifest and service worker* — The store now serves a web-app manifest at /manifest.webmanifest and a service worker at /sw.js, and links the manifest from every page, so the storefront can be installed as a progressive web app. A working default is served out of the box; an operator who configures a custom manifest overrides it. · *hreflang alternates for multilingual stores* — When a store runs locales via URL prefixes (e.g. /de/) or subdomains, each page now emits rel=alternate hreflang links (plus x-default) so search engines map the equivalent localized URLs instead of treating them as duplicate content. Stores on a single locale, or locale-by-cookie, emit none. · *Product Q&A rich results* — A product page with answered customer questions now emits QAPage structured data so the questions and answers are eligible for rich results in search. **Fixed:** *Localized blog, CMS, and policy pages on the cached path* — The cached (edge-rendered) blog articles, CMS pages, and privacy/terms pages now carry the correct document language and direction for the active locale, matching the dynamically rendered pages. Previously they were always marked English and left-to-right, which harmed screen readers and search indexing and broke right-to-left layout. · *Consistent robots.txt and complete sitemap* — robots.txt now disallows the admin, cart, checkout, order, and account paths consistently on both render paths, and the sitemap now includes collection, category, and CMS-page URLs alongside products and blog posts. A bug that made the dynamically rendered sitemap omit every product (a list limit overflow) is fixed so the full catalog is listed.
|
|
14
|
+
|
|
11
15
|
- v0.3.49 (2026-06-02) — **Real collection tiles on the home page, a working mobile nav, Collections/Categories in the header, and From-price on multi-variant products.** The home page's 'Featured collections' band linked to fixed keyword searches that landed on empty results unless a product happened to contain those words, the header navigation disappeared entirely on phones with no menu to replace it, and the two real catalog-browse surfaces (Collections and Categories) were reachable only from the footer. This release resolves those: the home tiles now render the store's actual collections with real links, the header gains a no-JavaScript mobile menu, and Collections and Categories appear in the primary nav. Multi-variant products now show a 'From <lowest price>' headline instead of always showing the first variant's price, the non-functional 'New arrivals'/'On sale' sort links are removed, and two small accessibility fixes land (the survey required-field marker and a dark color-scheme hint for native form controls). **Changed:** *Home 'Featured collections' tiles render real collections* — The featured-collections band on the home page now lists the store's actual collections with links to each collection page, instead of fixed keyword searches that could land on an empty results page. If no collections are defined, the band is omitted rather than shown empty. Both the cached and dynamically rendered home pages produce identical markup. · *Mobile navigation menu (no JavaScript required)* — On narrow screens the header now offers a disclosure menu that reveals the navigation links, which previously vanished entirely below a tablet width. It is a pure-CSS menu, so it works with JavaScript disabled. · *Collections and Categories in the header navigation* — The two catalog-browse surfaces — Collections and Categories — are now in the primary header navigation (and inside the mobile menu), not only in the footer. · *Multi-variant products show a 'From' price* — A product whose variants differ in price now shows a 'From <lowest price>' headline so the prominent price is no longer misleading when a shopper selects a different variant; each variant chip still shows its own exact price. · *Removed non-functional sort links* — The 'New arrivals' and 'On sale' links did nothing — the home page does not yet support sorting — so they have been removed rather than left as dead controls. They will return if and when catalog sorting is added. · *Accessibility: survey required marker and dark color-scheme* — The survey form's required-field marker is now announced correctly to screen readers, and the theme declares a dark color-scheme so native form controls, scrollbars, and autofill render correctly on the dark surface.
|
|
12
16
|
|
|
13
17
|
- v0.3.48 (2026-06-02) — **Customers can export or request deletion of their personal data, and the admin console gets a privacy-request queue to fulfil it.** The framework had a complete data-subject-request engine but nothing reached it, so a store handling EU/California personal data had no way for a shopper to exercise their right of access or erasure and no operator path to fulfil one. A signed-in customer can now request a full export of their data — account, addresses, orders, subscriptions, loyalty, and support history, streamed as a downloadable JSON bundle — and can file a deletion request. The admin console gets a Privacy requests screen where an operator reviews each request, downloads the export, previews a deletion (a dry run that changes nothing), and then executes it. Erasure anonymizes the customer's account and archives their addresses and subscriptions while retaining orders, loyalty ledger, and support tickets that must be kept for tax and legal records. A customer can only ever see or act on their own requests; deletion is always filed by the customer and carried out by an operator. **Added:** *Customer data export and deletion requests* — A signed-in customer gets a Privacy & data area under their account where they can request a full export of their personal data and file a deletion request. The export is streamed as a downloadable JSON bundle covering their account, addresses, orders, subscriptions, loyalty balance and ledger, and support history. A customer can only access their own requests and downloads; a request belonging to anyone else returns not-found. · *Privacy-request queue in the admin console* — A new Privacy requests screen lists every data-subject request with its status. An operator can download a request's export, preview a deletion as a dry run that reports what would change without modifying anything, and then execute the deletion behind a confirmation step. Deletion anonymizes the customer's account and archives their addresses and subscriptions, while orders, the loyalty ledger, and support tickets are retained for tax and legal record-keeping. Every customer- and operator-entered field shown in either console is HTML-escaped.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
2
|
+
"version": "0.3.51",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-/NizdENQkTc96romD0kmnl8220NAvW32QI0aSFuGiPbWE5lYbRxLXU5shsjlx5Ek",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
|
14
14
|
"fingerprinted": "js/announcement.59eab4872d2f3b4c.js"
|
|
15
15
|
},
|
|
16
|
+
"js/captcha.js": {
|
|
17
|
+
"integrity": "sha384-gsgNDf7cAOxXuZw5Fat4HiRUPCjKoU22W+3AIjNOx2l+i4lJXIHHNJdsXgarm3vy",
|
|
18
|
+
"fingerprinted": "js/captcha.2aab5803642f8ac5.js"
|
|
19
|
+
},
|
|
16
20
|
"js/cart-count.js": {
|
|
17
21
|
"integrity": "sha384-K/rkm//Dzg8nuOfpaeenJvLKKl+6DEuvuJi1LLgd46BK+dd1HYmU8R7/gHHjtEsr",
|
|
18
22
|
"fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
|
|
@@ -26,12 +30,20 @@
|
|
|
26
30
|
"fingerprinted": "js/passkey-add.b535e6a3eef4514e.js"
|
|
27
31
|
},
|
|
28
32
|
"js/passkey-login.js": {
|
|
29
|
-
"integrity": "sha384-
|
|
30
|
-
"fingerprinted": "js/passkey-login.
|
|
33
|
+
"integrity": "sha384-AbMxT4s0paFnZsEfAHfcpbS2ZRSmZ9KPnttEBy9SvWErBX3U+LhoeYYN7Nm3Y8AX",
|
|
34
|
+
"fingerprinted": "js/passkey-login.90c13d7c0d8667e2.js"
|
|
31
35
|
},
|
|
32
36
|
"js/passkey-register.js": {
|
|
33
|
-
"integrity": "sha384-
|
|
34
|
-
"fingerprinted": "js/passkey-register.
|
|
37
|
+
"integrity": "sha384-BjuUhbPZ18pHFMyOwT+309BEXu+VAc54RSj8lvN93jfFGDydEgmapiAOKTQM1yma",
|
|
38
|
+
"fingerprinted": "js/passkey-register.02b0e196fb9608d8.js"
|
|
39
|
+
},
|
|
40
|
+
"js/pay.js": {
|
|
41
|
+
"integrity": "sha384-b9H7SuK6CdLZtcZbTouW6uZ2G99L5/joX5z2YyiL7Slg2ub69ZNw2rPZ/Pxw4qBG",
|
|
42
|
+
"fingerprinted": "js/pay.e174b69ddffbbfe4.js"
|
|
43
|
+
},
|
|
44
|
+
"js/paypal-checkout.js": {
|
|
45
|
+
"integrity": "sha384-LI6y/1z0Y9F8Kx8RhW4EwY2WqJPXLwJozCXqnhDT+dTckLHyvhly0SsRpH0bsdui",
|
|
46
|
+
"fingerprinted": "js/paypal-checkout.b05ab5572cc3728f.js"
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
}
|
package/lib/locale-router.js
CHANGED
|
@@ -1042,11 +1042,50 @@ function create(opts) {
|
|
|
1042
1042
|
return out;
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
|
+
// Build the hreflang alternate set for `path` on `host` under the
|
|
1046
|
+
// active policy. Returns
|
|
1047
|
+
// { default_locale, x_default_url, alternates: [{ tag, href }, …] }
|
|
1048
|
+
// when an active `url_prefix` / `subdomain` policy exists — one entry per
|
|
1049
|
+
// supported locale, each `href` the `_canonicalForUrl` for that tag, plus
|
|
1050
|
+
// the `x_default_url` for the default locale. Returns null for a non-URL
|
|
1051
|
+
// strategy (cookie / accept-language), a single-locale policy, no active
|
|
1052
|
+
// policy, or a malformed host/path. Request-shape reader: never throws on
|
|
1053
|
+
// bad input (a hostile Host header must not 500 a page render) — it
|
|
1054
|
+
// returns null so callers emit no hreflang.
|
|
1055
|
+
async function alternateUrls(host, path) {
|
|
1056
|
+
var h, p;
|
|
1057
|
+
try { h = _host(host, "host"); } catch (_e) { return null; }
|
|
1058
|
+
try { p = _path(path, "path"); } catch (_e) { return null; }
|
|
1059
|
+
var policy;
|
|
1060
|
+
try { policy = await activePolicy(); } catch (_e) { return null; }
|
|
1061
|
+
if (!policy) return null;
|
|
1062
|
+
var strategy = policy.strategy;
|
|
1063
|
+
if (strategy !== "url_prefix" && strategy !== "subdomain") return null;
|
|
1064
|
+
var supported = policy.supported_locales;
|
|
1065
|
+
if (!Array.isArray(supported) || supported.length < 2) return null;
|
|
1066
|
+
var defaultLocale = policy.default_locale;
|
|
1067
|
+
var alternates = [];
|
|
1068
|
+
for (var i = 0; i < supported.length; i += 1) {
|
|
1069
|
+
var tag = supported[i];
|
|
1070
|
+
var href = _canonicalForUrl(h, p, tag, strategy);
|
|
1071
|
+
if (href === undefined) continue;
|
|
1072
|
+
alternates.push({ tag: tag, href: href });
|
|
1073
|
+
}
|
|
1074
|
+
if (!alternates.length) return null;
|
|
1075
|
+
var xDefault = _canonicalForUrl(h, p, defaultLocale, strategy);
|
|
1076
|
+
return {
|
|
1077
|
+
default_locale: defaultLocale,
|
|
1078
|
+
x_default_url: xDefault === undefined ? null : xDefault,
|
|
1079
|
+
alternates: alternates,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1045
1083
|
return {
|
|
1046
1084
|
KINDS: KINDS.slice(),
|
|
1047
1085
|
STRATEGIES: STRATEGIES.slice(),
|
|
1048
1086
|
LOCALE_RE: LOCALE_RE,
|
|
1049
1087
|
CURRENCY_RE: CURRENCY_RE,
|
|
1088
|
+
alternateUrls: alternateUrls,
|
|
1050
1089
|
defineLocale: defineLocale,
|
|
1051
1090
|
getLocale: getLocale,
|
|
1052
1091
|
listLocales: listLocales,
|
|
@@ -46,6 +46,16 @@
|
|
|
46
46
|
|
|
47
47
|
var b = require("./vendor/blamejs");
|
|
48
48
|
|
|
49
|
+
// The vendored strict default CSP, as a string constant, so a route can
|
|
50
|
+
// derive a SCOPED copy that admits a specific payment-processor / CAPTCHA-
|
|
51
|
+
// provider host on script-src/connect-src/frame-src WITHOUT relaxing the
|
|
52
|
+
// app-level default everywhere else (see scopedCsp). Required from the
|
|
53
|
+
// vendored module path because the constant is exported on the module, not
|
|
54
|
+
// hung off the `b.middleware.securityHeaders` factory function. This is a
|
|
55
|
+
// top-of-domain composition read of a read-only constant, not a vendored-
|
|
56
|
+
// tree edit.
|
|
57
|
+
var _vendoredSecurityHeaders = require("./vendor/blamejs/lib/middleware/security-headers");
|
|
58
|
+
|
|
49
59
|
var C = b.constants;
|
|
50
60
|
|
|
51
61
|
// Payment webhooks are server-to-server POSTs from Stripe / PayPal:
|
|
@@ -73,6 +83,8 @@ var HEALTH_PATH = "/_/health";
|
|
|
73
83
|
// /account/passkey/ -> register-begin / register-finish / add-* begin+finish
|
|
74
84
|
// /products/ -> the review + question submit sub-paths (gated to POST below)
|
|
75
85
|
// /survey/ -> /survey/:token (GET form + POST submit)
|
|
86
|
+
// /orders/ -> /orders/:id/cancel|rate|reorder (gated to POST below;
|
|
87
|
+
// the /orders/:id confirmation GET a shopper hits freely is NOT throttled)
|
|
76
88
|
var TIGHT_PREFIXES = [
|
|
77
89
|
"/account/login",
|
|
78
90
|
"/account/register",
|
|
@@ -83,6 +95,7 @@ var TIGHT_PREFIXES = [
|
|
|
83
95
|
"/newsletter",
|
|
84
96
|
"/products/",
|
|
85
97
|
"/survey/",
|
|
98
|
+
"/orders/",
|
|
86
99
|
];
|
|
87
100
|
|
|
88
101
|
// Edge-served state-changing POST endpoints. These forms are rendered at
|
|
@@ -176,6 +189,150 @@ function securityHeadersOpts() {
|
|
|
176
189
|
return { documentPolicy: false };
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
// ---- route-scoped CSP (payment processors + CAPTCHA providers) ----------
|
|
193
|
+
//
|
|
194
|
+
// The app-level CSP is the vendored strict default: `script-src 'self'`,
|
|
195
|
+
// `require-trusted-types-for 'script'`, `object-src 'none'`,
|
|
196
|
+
// `default-src 'self'`, `frame-ancestors 'none'` — no inline scripts, no
|
|
197
|
+
// third-party hosts. A few WRITE-side pages legitimately load a third-
|
|
198
|
+
// party SDK from a fixed host: the Stripe / PayPal payment pages, and the
|
|
199
|
+
// auth + checkout pages when an operator has enabled a CAPTCHA provider.
|
|
200
|
+
// Those SDK hosts can't be `'self'`, so the page needs a route-SCOPED CSP
|
|
201
|
+
// that admits ONLY the specific provider origin on the directives that
|
|
202
|
+
// provider needs (script-src / connect-src / frame-src). Every other
|
|
203
|
+
// protection — Trusted Types, object-src, default-src, frame-ancestors —
|
|
204
|
+
// stays exactly as the strict default. The inline glue that drives those
|
|
205
|
+
// SDKs lives in external same-origin islands (themes/default/assets/js/*),
|
|
206
|
+
// so `script-src` never needs `'unsafe-inline'` or a nonce.
|
|
207
|
+
//
|
|
208
|
+
// `scopedCsp(keys)` returns a CSP string for `res.setHeader(
|
|
209
|
+
// "content-security-policy", ...)` on the route's response. Node's
|
|
210
|
+
// setHeader OVERWRITES, so the route's header replaces the app-level
|
|
211
|
+
// default for that one response only; no other route is affected.
|
|
212
|
+
|
|
213
|
+
// The fixed, public host set per provider. Single source of truth —
|
|
214
|
+
// every page that admits a provider names it here, never inline. Only the
|
|
215
|
+
// directives the provider actually loads from are listed (Stripe needs all
|
|
216
|
+
// three; PayPal needs script + frame + connect; a CAPTCHA widget needs all
|
|
217
|
+
// three). Hosts are scheme-qualified https origins, never wildcards beyond
|
|
218
|
+
// what the provider's own CDN requires.
|
|
219
|
+
var CSP_HOSTS = {
|
|
220
|
+
stripe: {
|
|
221
|
+
script: ["https://js.stripe.com"],
|
|
222
|
+
connect: ["https://api.stripe.com"],
|
|
223
|
+
frame: ["https://js.stripe.com", "https://hooks.stripe.com"],
|
|
224
|
+
},
|
|
225
|
+
paypal: {
|
|
226
|
+
script: ["https://www.paypal.com", "https://www.paypalobjects.com"],
|
|
227
|
+
frame: ["https://www.paypal.com"],
|
|
228
|
+
connect: ["https://www.paypal.com"],
|
|
229
|
+
},
|
|
230
|
+
// CAPTCHA providers (only the active provider's host is added per request).
|
|
231
|
+
turnstile: {
|
|
232
|
+
script: ["https://challenges.cloudflare.com"],
|
|
233
|
+
frame: ["https://challenges.cloudflare.com"],
|
|
234
|
+
connect: ["https://challenges.cloudflare.com"],
|
|
235
|
+
},
|
|
236
|
+
hcaptcha: {
|
|
237
|
+
script: ["https://js.hcaptcha.com", "https://hcaptcha.com"],
|
|
238
|
+
frame: ["https://hcaptcha.com", "https://*.hcaptcha.com"],
|
|
239
|
+
connect: ["https://hcaptcha.com", "https://*.hcaptcha.com"],
|
|
240
|
+
},
|
|
241
|
+
recaptcha: {
|
|
242
|
+
script: ["https://www.google.com", "https://www.gstatic.com"],
|
|
243
|
+
frame: ["https://www.google.com"],
|
|
244
|
+
connect: ["https://www.google.com"],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Directive name → the CSP_HOSTS sub-key that feeds it.
|
|
249
|
+
var _SCOPED_DIRECTIVES = {
|
|
250
|
+
"script-src": "script",
|
|
251
|
+
"connect-src": "connect",
|
|
252
|
+
"frame-src": "frame",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Directives the scoped builder MUST NOT touch — relaxing any of these
|
|
256
|
+
// would weaken the page's defense, which is the whole point this group
|
|
257
|
+
// guards against. Listed for the in-file invariant; the builder only ever
|
|
258
|
+
// appends to the three _SCOPED_DIRECTIVES entries.
|
|
259
|
+
// default-src 'self', object-src 'none',
|
|
260
|
+
// require-trusted-types-for 'script', frame-ancestors 'none', base-uri 'self'
|
|
261
|
+
|
|
262
|
+
// Parse the vendored default CSP into an ordered directive map ONCE at
|
|
263
|
+
// module load. The default is a `name a b; name c;` string; split on `;`,
|
|
264
|
+
// the first token of each clause is the directive name, the rest its
|
|
265
|
+
// source list.
|
|
266
|
+
function _parseCsp(cspString) {
|
|
267
|
+
var order = [];
|
|
268
|
+
var map = {};
|
|
269
|
+
String(cspString).split(";").forEach(function (clause) {
|
|
270
|
+
var trimmed = clause.trim();
|
|
271
|
+
if (!trimmed) return;
|
|
272
|
+
var parts = trimmed.split(/\s+/);
|
|
273
|
+
var name = parts[0];
|
|
274
|
+
order.push(name);
|
|
275
|
+
map[name] = parts.slice(1);
|
|
276
|
+
});
|
|
277
|
+
return { order: order, map: map };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var _DEFAULT_CSP_PARSED = _parseCsp(_vendoredSecurityHeaders.DEFAULT_CSP);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build a route-scoped CSP string from the vendored strict default plus the
|
|
284
|
+
* named providers' hosts. `keys` is an array of CSP_HOSTS keys
|
|
285
|
+
* ("stripe" / "paypal" / "turnstile" / "hcaptcha" / "recaptcha"); each
|
|
286
|
+
* adds its hosts to script-src / connect-src / frame-src (creating the
|
|
287
|
+
* directive from `'self'` if the default omits it, e.g. frame-src). Every
|
|
288
|
+
* other directive — including require-trusted-types-for, object-src,
|
|
289
|
+
* default-src, frame-ancestors — is carried through verbatim. Unknown keys
|
|
290
|
+
* are ignored (a defensive request-shape reader: a caller passing a
|
|
291
|
+
* mistyped key gets the strict default for that key, never a throw on the
|
|
292
|
+
* hot path of rendering a pay page). Returns the strict default unchanged
|
|
293
|
+
* when `keys` is empty / not an array.
|
|
294
|
+
*
|
|
295
|
+
* Config-time-ish but called per-response on a render path, so it returns
|
|
296
|
+
* defaults rather than throwing — a bad key degrades to the strict default,
|
|
297
|
+
* which fails safe (the SDK is refused, not admitted too broadly).
|
|
298
|
+
*/
|
|
299
|
+
function scopedCsp(keys) {
|
|
300
|
+
// Clone the parsed default so the per-call mutation never leaks into the
|
|
301
|
+
// module-level snapshot.
|
|
302
|
+
var order = _DEFAULT_CSP_PARSED.order.slice();
|
|
303
|
+
var map = {};
|
|
304
|
+
_DEFAULT_CSP_PARSED.order.forEach(function (name) {
|
|
305
|
+
map[name] = _DEFAULT_CSP_PARSED.map[name].slice();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (Array.isArray(keys)) {
|
|
309
|
+
keys.forEach(function (key) {
|
|
310
|
+
var hostSet = CSP_HOSTS[key];
|
|
311
|
+
if (!hostSet) return;
|
|
312
|
+
Object.keys(_SCOPED_DIRECTIVES).forEach(function (directive) {
|
|
313
|
+
var subKey = _SCOPED_DIRECTIVES[directive];
|
|
314
|
+
var hosts = hostSet[subKey];
|
|
315
|
+
if (!hosts || !hosts.length) return;
|
|
316
|
+
if (!map[directive]) {
|
|
317
|
+
// The default omits frame-src (it relies on the default-src /
|
|
318
|
+
// fenced-frame-src pair); create it from 'self' so admitting a
|
|
319
|
+
// provider frame doesn't accidentally open it to everything.
|
|
320
|
+
map[directive] = ["'self'"];
|
|
321
|
+
order.push(directive);
|
|
322
|
+
}
|
|
323
|
+
hosts.forEach(function (h) {
|
|
324
|
+
if (map[directive].indexOf(h) === -1) map[directive].push(h);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return order.map(function (name) {
|
|
331
|
+
var srcs = map[name];
|
|
332
|
+
return srcs.length ? (name + " " + srcs.join(" ")) : name;
|
|
333
|
+
}).join("; ") + ";";
|
|
334
|
+
}
|
|
335
|
+
|
|
179
336
|
/**
|
|
180
337
|
* Build the GLOBAL rate-limit options for createApp's
|
|
181
338
|
* `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
|
|
@@ -298,15 +455,19 @@ function mountRouteGuards(r) {
|
|
|
298
455
|
// Never throttle the webhook or health paths.
|
|
299
456
|
if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
|
|
300
457
|
if (!_hasPrefix(pathname, TIGHT_PREFIXES)) return next();
|
|
301
|
-
// `/products
|
|
302
|
-
// render) that a shopper hits
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
458
|
+
// `/products/`, `/gift-cards`, and `/orders/` carry GET reads (PDP,
|
|
459
|
+
// balance page render, order confirmation page) that a shopper hits
|
|
460
|
+
// freely — only gate the state-changing / lookup POSTs there. For
|
|
461
|
+
// `/orders/` that's the cancel/rate/reorder mutations; the
|
|
462
|
+
// `GET /orders/:id` confirmation page (a customer reloads it
|
|
463
|
+
// repeatedly) is never throttled. Login / register / passkey /
|
|
464
|
+
// checkout / newsletter / survey are gated on every method (their
|
|
465
|
+
// GETs are the form render, which a spray would hammer just the same
|
|
466
|
+
// to harvest a fresh token, so the budget covers both).
|
|
307
467
|
var method = (req.method || "GET").toUpperCase();
|
|
308
468
|
var gateAllMethods = pathname.indexOf("/products/") !== 0 &&
|
|
309
|
-
pathname.indexOf("/gift-cards") !== 0
|
|
469
|
+
pathname.indexOf("/gift-cards") !== 0 &&
|
|
470
|
+
pathname.indexOf("/orders/") !== 0;
|
|
310
471
|
if (!gateAllMethods && method !== "POST") return next();
|
|
311
472
|
return tightLimiter(req, res, next);
|
|
312
473
|
});
|
|
@@ -319,6 +480,8 @@ module.exports = {
|
|
|
319
480
|
securityHeadersOpts: securityHeadersOpts,
|
|
320
481
|
globalRateLimitOpts: globalRateLimitOpts,
|
|
321
482
|
mountRouteGuards: mountRouteGuards,
|
|
483
|
+
scopedCsp: scopedCsp,
|
|
484
|
+
CSP_HOSTS: CSP_HOSTS,
|
|
322
485
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
323
486
|
HEALTH_PATH: HEALTH_PATH,
|
|
324
487
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
package/lib/storefront.js
CHANGED
|
@@ -267,9 +267,11 @@ var LAYOUT =
|
|
|
267
267
|
" <meta name=\"description\" content=\"{{og_description}}\">\n" +
|
|
268
268
|
" <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
|
|
269
269
|
"RAW_ROBOTS_META" +
|
|
270
|
+
"RAW_HREFLANG" +
|
|
270
271
|
" <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
|
|
271
272
|
" <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
|
|
272
273
|
" <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
|
|
274
|
+
" <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n" +
|
|
273
275
|
" <meta name=\"theme-color\" content=\"#08080a\">\n" +
|
|
274
276
|
" <link rel=\"stylesheet\" href=\"{{theme_css}}\"RAW_CSS_INTEGRITY>\n" +
|
|
275
277
|
" <meta property=\"og:type\" content=\"{{og_type}}\">\n" +
|
|
@@ -854,9 +856,20 @@ function _wrap(opts) {
|
|
|
854
856
|
? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
|
|
855
857
|
: "";
|
|
856
858
|
|
|
859
|
+
// hreflang alternates — pre-rendered HTML seeded onto the locale ALS
|
|
860
|
+
// store by `localeMiddleware` (kept synchronous so it reaches the
|
|
861
|
+
// handler), or threaded explicitly by a renderer/unit test. Empty for a
|
|
862
|
+
// single-locale store or a cookie/accept-language strategy (no URL-shaped
|
|
863
|
+
// alternates to emit). Byte-identical to the edge's `alternateLinks`
|
|
864
|
+
// output for the same (host, path, supported, default, strategy) — a
|
|
865
|
+
// parity test pins the two.
|
|
866
|
+
var hreflangHtml = (typeof opts.alternate_links === "string")
|
|
867
|
+
? opts.alternate_links
|
|
868
|
+
: ((storeCtx && typeof storeCtx.alternate_links === "string") ? storeCtx.alternate_links : "");
|
|
857
869
|
var assembled = _render(LAYOUT, vars)
|
|
858
870
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
859
871
|
.replace("RAW_ROBOTS_META", robotsMeta)
|
|
872
|
+
.replace("RAW_HREFLANG", hreflangHtml)
|
|
860
873
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
861
874
|
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
862
875
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
@@ -5713,6 +5726,143 @@ function _jsonLdScript(data) {
|
|
|
5713
5726
|
return "<script type=\"application/ld+json\">" + serialised + "</script>";
|
|
5714
5727
|
}
|
|
5715
5728
|
|
|
5729
|
+
// PWA default manifest + service worker — the shipped fallback served at
|
|
5730
|
+
// /manifest.webmanifest + /sw.js when the operator has not defined an
|
|
5731
|
+
// active pwaManifest row. BOTH substrates serve these exact bytes (the
|
|
5732
|
+
// edge has no DB-backed primitive; the container's DB-backed pwaManifest is
|
|
5733
|
+
// the override), so the `<link rel="manifest">` in every layout never 404s
|
|
5734
|
+
// on a fresh deploy. The byte-identical twin of
|
|
5735
|
+
// worker/render/_lib.js's PWA_DEFAULT_MANIFEST / PWA_DEFAULT_SW — a parity
|
|
5736
|
+
// test pins them. An operator customizes via pwaManifest.defineManifest +
|
|
5737
|
+
// setActive on the container path.
|
|
5738
|
+
var _PWA_DEFAULT_MANIFEST_OBJ = {
|
|
5739
|
+
name: "blamejs.shop",
|
|
5740
|
+
short_name: "blamejs.shop",
|
|
5741
|
+
description: "Open-source ecommerce framework built on blamejs — server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.",
|
|
5742
|
+
start_url: "/",
|
|
5743
|
+
scope: "/",
|
|
5744
|
+
display: "standalone",
|
|
5745
|
+
orientation: "portrait",
|
|
5746
|
+
theme_color: "#08080a",
|
|
5747
|
+
background_color: "#08080a",
|
|
5748
|
+
lang: "en",
|
|
5749
|
+
dir: "ltr",
|
|
5750
|
+
icons: [
|
|
5751
|
+
{ src: "/assets/brand/favicon.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
5752
|
+
{ src: "/assets/brand/favicon.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
|
|
5753
|
+
{ src: "/assets/brand/favicon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }
|
|
5754
|
+
]
|
|
5755
|
+
};
|
|
5756
|
+
var PWA_DEFAULT_MANIFEST = JSON.stringify(_PWA_DEFAULT_MANIFEST_OBJ, null, 2);
|
|
5757
|
+
|
|
5758
|
+
var PWA_DEFAULT_SW =
|
|
5759
|
+
"// service worker — shipped default (pass-through navigation fallback)\n" +
|
|
5760
|
+
"\"use strict\";\n" +
|
|
5761
|
+
"var CACHE_NAME = \"blamejs-shop-default-v1\";\n" +
|
|
5762
|
+
"self.addEventListener(\"install\", function (e) { self.skipWaiting(); });\n" +
|
|
5763
|
+
"self.addEventListener(\"activate\", function (e) {\n" +
|
|
5764
|
+
" e.waitUntil(caches.keys().then(function (keys) {\n" +
|
|
5765
|
+
" return Promise.all(keys.map(function (k) { if (k !== CACHE_NAME) return caches.delete(k); }));\n" +
|
|
5766
|
+
" }).then(function () { return self.clients.claim(); }));\n" +
|
|
5767
|
+
"});\n" +
|
|
5768
|
+
"self.addEventListener(\"fetch\", function (e) {\n" +
|
|
5769
|
+
" if (e.request.method !== \"GET\") return;\n" +
|
|
5770
|
+
" if (e.request.mode === \"navigate\") {\n" +
|
|
5771
|
+
" e.respondWith(fetch(e.request).catch(function () { return caches.match(\"/\"); }));\n" +
|
|
5772
|
+
" }\n" +
|
|
5773
|
+
"});\n";
|
|
5774
|
+
|
|
5775
|
+
// QAPage JSON-LD from the PDP's published Q&A threads. Surfaces the
|
|
5776
|
+
// question + its answers in Google's Q&A rich result. Emitted only when at
|
|
5777
|
+
// least one question has at least one answer — Google rejects a QAPage
|
|
5778
|
+
// whose Question carries neither an `acceptedAnswer` nor a
|
|
5779
|
+
// `suggestedAnswer`. The first answer is the `acceptedAnswer`; the rest are
|
|
5780
|
+
// `suggestedAnswer`. `q.body` / `a.body` are operator/customer free text —
|
|
5781
|
+
// `_jsonLdScript` JSON.stringify's them (neutralizing quotes + the
|
|
5782
|
+
// `</script` breakout), so they are NOT additionally HTML-escaped (JSON-LD
|
|
5783
|
+
// is not HTML, by the documented `_jsonLdScript` contract — the same trust
|
|
5784
|
+
// model as the Product JSON-LD `description`). The byte-identical twin of
|
|
5785
|
+
// the edge's `worker/render/product.js#_buildQaPageJsonLd`; a parity test
|
|
5786
|
+
// pins the two. Returns "" when there is nothing rich-result-eligible.
|
|
5787
|
+
function _buildQaPageJsonLd(questions) {
|
|
5788
|
+
questions = questions || [];
|
|
5789
|
+
var mainEntity = [];
|
|
5790
|
+
for (var i = 0; i < questions.length; i += 1) {
|
|
5791
|
+
var q = questions[i];
|
|
5792
|
+
var answers = (q && q.answers) || [];
|
|
5793
|
+
if (answers.length === 0) continue;
|
|
5794
|
+
var question = {
|
|
5795
|
+
"@type": "Question",
|
|
5796
|
+
"name": String(q.body),
|
|
5797
|
+
"answerCount": answers.length,
|
|
5798
|
+
"acceptedAnswer": { "@type": "Answer", "text": String(answers[0].body) },
|
|
5799
|
+
};
|
|
5800
|
+
if (answers.length > 1) {
|
|
5801
|
+
var suggested = [];
|
|
5802
|
+
for (var j = 1; j < answers.length; j += 1) {
|
|
5803
|
+
suggested.push({ "@type": "Answer", "text": String(answers[j].body) });
|
|
5804
|
+
}
|
|
5805
|
+
question.suggestedAnswer = suggested;
|
|
5806
|
+
}
|
|
5807
|
+
mainEntity.push(question);
|
|
5808
|
+
}
|
|
5809
|
+
if (mainEntity.length === 0) return "";
|
|
5810
|
+
return _jsonLdScript({
|
|
5811
|
+
"@context": "https://schema.org",
|
|
5812
|
+
"@type": "QAPage",
|
|
5813
|
+
"mainEntity": mainEntity,
|
|
5814
|
+
});
|
|
5815
|
+
}
|
|
5816
|
+
|
|
5817
|
+
// ---- hreflang alternates (URL-shaped locale policies) ------------------
|
|
5818
|
+
//
|
|
5819
|
+
// The href math + validation envelopes below are the byte-identical twin
|
|
5820
|
+
// of worker/render/chrome-i18n.js's `alternateLinks` so the edge + the
|
|
5821
|
+
// container emit the same `<link rel="alternate" hreflang>` block for the
|
|
5822
|
+
// same (host, path, supported, default, strategy). A parity test pins the
|
|
5823
|
+
// two. Both mirror lib/locale-router.js#_canonicalForUrl's prefix /
|
|
5824
|
+
// subdomain substitution.
|
|
5825
|
+
var _HREFLANG_TAG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
5826
|
+
var _HREFLANG_HOST_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
5827
|
+
|
|
5828
|
+
function _hreflangStripLeadingSegment(path) {
|
|
5829
|
+
var rest = path.charAt(0) === "/" ? path.slice(1) : path;
|
|
5830
|
+
if (!rest.length) return null;
|
|
5831
|
+
var slash = rest.indexOf("/");
|
|
5832
|
+
var seg = slash === -1 ? rest : rest.slice(0, slash);
|
|
5833
|
+
if (!seg.length || seg.length > 35 || !_HREFLANG_TAG_RE.test(seg)) return null;
|
|
5834
|
+
return seg;
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
function _hreflangStripLeadingSubdomain(host) {
|
|
5838
|
+
if (host.indexOf(".") === -1) return null;
|
|
5839
|
+
var first = host.slice(0, host.indexOf("."));
|
|
5840
|
+
if (!first.length || first.length > 35 || !_HREFLANG_TAG_RE.test(first)) return null;
|
|
5841
|
+
return first;
|
|
5842
|
+
}
|
|
5843
|
+
|
|
5844
|
+
function _canonicalForLocaleUrl(host, path, locale, strategy) {
|
|
5845
|
+
var lc = String(locale).toLowerCase();
|
|
5846
|
+
if (strategy === "url_prefix") {
|
|
5847
|
+
var prefix = _hreflangStripLeadingSegment(path);
|
|
5848
|
+
var rest;
|
|
5849
|
+
if (prefix) {
|
|
5850
|
+
rest = path.slice(1 + prefix.length);
|
|
5851
|
+
if (rest.length === 0) rest = "/";
|
|
5852
|
+
} else {
|
|
5853
|
+
rest = path;
|
|
5854
|
+
}
|
|
5855
|
+
if (rest.charAt(0) !== "/") rest = "/" + rest;
|
|
5856
|
+
return "https://" + host + "/" + lc + (rest === "/" ? "" : rest);
|
|
5857
|
+
}
|
|
5858
|
+
if (strategy === "subdomain") {
|
|
5859
|
+
var sub = _hreflangStripLeadingSubdomain(host);
|
|
5860
|
+
var apex = sub ? host.slice(sub.length + 1) : host;
|
|
5861
|
+
return "https://" + lc + "." + apex + path;
|
|
5862
|
+
}
|
|
5863
|
+
return null;
|
|
5864
|
+
}
|
|
5865
|
+
|
|
5716
5866
|
function renderProduct(opts) {
|
|
5717
5867
|
if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
|
|
5718
5868
|
var variants = opts.variants || [];
|
|
@@ -5902,7 +6052,10 @@ function renderProduct(opts) {
|
|
|
5902
6052
|
{ "@type": "ListItem", "position": 2, "name": opts.product.title, "item": absoluteBase + "/products/" + opts.product.slug },
|
|
5903
6053
|
],
|
|
5904
6054
|
});
|
|
5905
|
-
|
|
6055
|
+
// QAPage JSON-LD from the published Q&A (empty string when no question is
|
|
6056
|
+
// answered — Google rejects an answerless QAPage). Mirrors the edge.
|
|
6057
|
+
var qaPageJsonLd = _buildQaPageJsonLd(opts.qa_questions);
|
|
6058
|
+
jsonLd = (jsonLd || "") + breadcrumbJsonLd + qaPageJsonLd;
|
|
5906
6059
|
|
|
5907
6060
|
return _wrap(Object.assign({
|
|
5908
6061
|
title: opts.product.title,
|
|
@@ -6134,8 +6287,14 @@ function renderCheckoutForm(opts) {
|
|
|
6134
6287
|
var grandTotal = pricing.format(
|
|
6135
6288
|
dTotals.grand_total_minor == null ? dTotals.subtotal_minor : dTotals.grand_total_minor,
|
|
6136
6289
|
dTotals.currency);
|
|
6290
|
+
// CAPTCHA widget block (no-op unless a provider is active for checkout).
|
|
6291
|
+
// Computed BEFORE the themed early-return so a themed checkout gets the
|
|
6292
|
+
// same challenge as the default render — otherwise a configured provider
|
|
6293
|
+
// would render a themed form with no widget yet have POST /checkout fail
|
|
6294
|
+
// closed on the missing token, breaking every themed-store checkout.
|
|
6295
|
+
var checkoutCaptcha = _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, true);
|
|
6137
6296
|
if (opts.theme) {
|
|
6138
|
-
|
|
6297
|
+
var themed = opts.theme.render("checkout", {
|
|
6139
6298
|
title: "Checkout",
|
|
6140
6299
|
shop_name: shopName,
|
|
6141
6300
|
cart_count: lines.length,
|
|
@@ -6143,6 +6302,8 @@ function renderCheckoutForm(opts) {
|
|
|
6143
6302
|
total: grandTotal,
|
|
6144
6303
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
6145
6304
|
});
|
|
6305
|
+
if (checkoutCaptcha) themed = themed.replace("</form>", checkoutCaptcha + "</form>");
|
|
6306
|
+
return themed;
|
|
6146
6307
|
}
|
|
6147
6308
|
// Order-summary line items in the sticky rail — same thumbnail + title
|
|
6148
6309
|
// pattern as the cart, compact. Formatted in the order's own currency
|
|
@@ -6169,6 +6330,15 @@ function renderCheckoutForm(opts) {
|
|
|
6169
6330
|
.replace("RAW_INLINE_ERROR", inlineError)
|
|
6170
6331
|
.replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
|
|
6171
6332
|
.replace("RAW_SUMMARY_LINES", summaryLines);
|
|
6333
|
+
// CAPTCHA challenge inside the form — when the operator has an active
|
|
6334
|
+
// provider (opts.captcha_kind + opts.captcha_public_key set), the widget
|
|
6335
|
+
// renders just above the submit button so its hidden token field rides the
|
|
6336
|
+
// form POST. Absent a provider this is "" — the checkout is byte-identical
|
|
6337
|
+
// to the unconfigured store. The route-scoped CSP that admits the provider
|
|
6338
|
+
// SDK host is set by the GET/POST /checkout handlers.
|
|
6339
|
+
if (checkoutCaptcha) {
|
|
6340
|
+
body = body.replace("</form>", checkoutCaptcha + "</form>");
|
|
6341
|
+
}
|
|
6172
6342
|
// Signed-in customer with a spendable points balance — surface a
|
|
6173
6343
|
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
6174
6344
|
// balance + value are numbers we control, the conversion ratio is the
|
|
@@ -6257,35 +6427,78 @@ function renderGiftCardBalance(opts) {
|
|
|
6257
6427
|
});
|
|
6258
6428
|
}
|
|
6259
6429
|
|
|
6430
|
+
// PayPal Smart Buttons block, appended to the checkout form when PayPal is
|
|
6431
|
+
// configured. The SDK `<script src>` is admitted by the checkout page's
|
|
6432
|
+
// route-scoped CSP (the handler adds the "paypal" host set when
|
|
6433
|
+
// deps.paypal_client_id is set); the create/capture glue lives in the
|
|
6434
|
+
// external same-origin `paypal-checkout.js` island — NO inline `<script>`.
|
|
6435
|
+
// client-id + currency ride attribute-escaped `data-*` attributes the island
|
|
6436
|
+
// reads, never interpolated into executable script.
|
|
6260
6437
|
function _paypalCheckoutBlock(clientId, currency) {
|
|
6261
6438
|
var esc = b.template.escapeHtml;
|
|
6262
6439
|
var cid = esc(String(clientId));
|
|
6263
6440
|
var cur = esc(String(currency || "USD"));
|
|
6264
|
-
return "\n<div class=\"checkout-paypal\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
6441
|
+
return "\n<div class=\"checkout-paypal\" id=\"paypal-island\" data-paypal-client-id=\"" + cid + "\" data-currency=\"" + cur + "\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
6265
6442
|
"<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
|
|
6266
6443
|
"<div id=\"paypal-button-container\"></div>" +
|
|
6267
6444
|
"<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
|
|
6268
6445
|
"</div>" +
|
|
6269
6446
|
"<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "¤cy=" + cur + "&intent=capture\"></script>" +
|
|
6270
|
-
"
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
"
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
}
|
|
6284
|
-
|
|
6285
|
-
//
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6447
|
+
_islandScript("paypal-checkout.js") + "\n";
|
|
6448
|
+
}
|
|
6449
|
+
|
|
6450
|
+
// CAPTCHA provider host the provider's challenge SDK is loaded from. The
|
|
6451
|
+
// kind→URL map mirrors the well-known siteverify/api.js endpoints. Only the
|
|
6452
|
+
// `api.js` SDK is rendered here; the siteverify call is server-side egress
|
|
6453
|
+
// (server.js captchaVerify). All four kinds are covered so any operator-
|
|
6454
|
+
// registered provider renders without a code change.
|
|
6455
|
+
var _CAPTCHA_SDK = {
|
|
6456
|
+
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
|
6457
|
+
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
|
6458
|
+
recaptcha_v2: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
6459
|
+
recaptcha_v3: "https://www.google.com/recaptcha/api.js?render=explicit",
|
|
6460
|
+
};
|
|
6461
|
+
|
|
6462
|
+
// Map a captcha kind to the CSP_HOSTS key that admits its SDK host.
|
|
6463
|
+
function _captchaCspKey(kind) {
|
|
6464
|
+
if (kind === "recaptcha_v2" || kind === "recaptcha_v3") return "recaptcha";
|
|
6465
|
+
return kind; // turnstile | hcaptcha
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
// The CAPTCHA widget block: a mount div carrying the (escaped) kind +
|
|
6469
|
+
// sitekey, the provider SDK `<script src>` (admitted by the route-scoped
|
|
6470
|
+
// CSP the handler sets when a provider is active), the external `captcha.js`
|
|
6471
|
+
// island that renders the widget + exposes the token, and a hidden field so
|
|
6472
|
+
// the token rides a plain form POST (checkout). `withField` is false for the
|
|
6473
|
+
// JSON-ceremony pages (login/register), which read the token via
|
|
6474
|
+
// window.__captchaToken() instead. Returns "" when no provider is active —
|
|
6475
|
+
// the unconfigured-store no-op.
|
|
6476
|
+
function _captchaWidgetBlock(kind, sitekey, withField) {
|
|
6477
|
+
if (!kind || !sitekey) return "";
|
|
6478
|
+
var esc = b.template.escapeHtml;
|
|
6479
|
+
var sdk = _CAPTCHA_SDK[kind];
|
|
6480
|
+
if (!sdk) return "";
|
|
6481
|
+
var hidden = withField
|
|
6482
|
+
? "<input type=\"hidden\" name=\"captcha_token\" id=\"captcha-token-field\" value=\"\">"
|
|
6483
|
+
: "";
|
|
6484
|
+
return "\n<div class=\"captcha-block\">" +
|
|
6485
|
+
"<div id=\"captcha-widget\" data-captcha-kind=\"" + esc(String(kind)) + "\" data-sitekey=\"" + esc(String(sitekey)) + "\"></div>" +
|
|
6486
|
+
hidden +
|
|
6487
|
+
"</div>" +
|
|
6488
|
+
"<script src=\"" + esc(sdk) + "\" async defer></script>" +
|
|
6489
|
+
_islandScript("captcha.js") + "\n";
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
// Stripe Elements payment page — loads Stripe.js (the only third-party
|
|
6493
|
+
// script, admitted by the route-scoped CSP set on GET /pay/:order_id) and
|
|
6494
|
+
// an external same-origin island (`pay.js`) that mounts the Payment Element.
|
|
6495
|
+
// No inline `<script>`: the strict default CSP's
|
|
6496
|
+
// `require-trusted-types-for 'script'` + the absence of `'unsafe-inline'`
|
|
6497
|
+
// would block one. The publishable key (operator-supplied env
|
|
6498
|
+
// `STRIPE_PUBLISHABLE_KEY`) and the per-order PaymentIntent client_secret
|
|
6499
|
+
// ride HTML-attribute-escaped `data-*` attributes on the mount div — read
|
|
6500
|
+
// by the island, never interpolated into executable script. The
|
|
6501
|
+
// client_secret is per-order; never logged, never persisted.
|
|
6289
6502
|
var PAY_PAGE =
|
|
6290
6503
|
"<section class=\"pay-page\">\n" +
|
|
6291
6504
|
" <header class=\"section-head\">\n" +
|
|
@@ -6293,7 +6506,7 @@ var PAY_PAGE =
|
|
|
6293
6506
|
" <h1 class=\"section-head__title\">Pay {{grand_total}}</h1>\n" +
|
|
6294
6507
|
" <p class=\"section-head__lede\">Order <code class=\"inline-code\">{{order_id}}</code> · the Stripe Payment Element is mounted below in a same-origin form.</p>\n" +
|
|
6295
6508
|
" </header>\n" +
|
|
6296
|
-
" <div class=\"pay-card\">\n" +
|
|
6509
|
+
" <div class=\"pay-card\" id=\"pay-island\" data-pk=\"{{pk}}\" data-cs=\"{{client_secret}}\" data-order=\"{{order_id}}\">\n" +
|
|
6297
6510
|
" <div id=\"express-checkout\" class=\"pay-card__express\" hidden>\n" +
|
|
6298
6511
|
" <div id=\"express-checkout-element\"></div>\n" +
|
|
6299
6512
|
" <div class=\"pay-card__divider\"><span>or pay with card</span></div>\n" +
|
|
@@ -6303,35 +6516,7 @@ var PAY_PAGE =
|
|
|
6303
6516
|
" <p id=\"payment-message\" class=\"pay-card__message\"></p>\n" +
|
|
6304
6517
|
" </div>\n" +
|
|
6305
6518
|
" <script src=\"https://js.stripe.com/v3/\"></script>\n" +
|
|
6306
|
-
"
|
|
6307
|
-
" (function () {\n" +
|
|
6308
|
-
" var stripe = Stripe({{pk_json}});\n" +
|
|
6309
|
-
" var elements = stripe.elements({ clientSecret: {{client_secret_json}}, appearance: { theme: \"stripe\" } });\n" +
|
|
6310
|
-
" var returnUrl = window.location.origin + \"/orders/{{order_id}}\";\n" +
|
|
6311
|
-
" var message = document.getElementById(\"payment-message\");\n" +
|
|
6312
|
-
" function confirm() {\n" +
|
|
6313
|
-
" message.textContent = \"Processing...\";\n" +
|
|
6314
|
-
" return stripe.confirmPayment({ elements: elements, confirmParams: { return_url: returnUrl } }).then(function (result) {\n" +
|
|
6315
|
-
" if (result.error) { message.textContent = result.error.message || \"Payment failed.\"; }\n" +
|
|
6316
|
-
" });\n" +
|
|
6317
|
-
" }\n" +
|
|
6318
|
-
" // Express Checkout Element — renders Apple Pay / Google Pay /\n" +
|
|
6319
|
-
" // Link wallet buttons when the device + the shop's registered\n" +
|
|
6320
|
-
" // payment-method domain make them available. It confirms the\n" +
|
|
6321
|
-
" // same PaymentIntent as the card form, so the webhook + order\n" +
|
|
6322
|
-
" // FSM are identical. Hidden until Stripe reports an available\n" +
|
|
6323
|
-
" // wallet so the divider never sits over an empty box.\n" +
|
|
6324
|
-
" var ece = elements.create(\"expressCheckout\");\n" +
|
|
6325
|
-
" ece.on(\"ready\", function (ev) {\n" +
|
|
6326
|
-
" if (ev && ev.availablePaymentMethods) { document.getElementById(\"express-checkout\").hidden = false; }\n" +
|
|
6327
|
-
" });\n" +
|
|
6328
|
-
" ece.on(\"confirm\", function () { confirm(); });\n" +
|
|
6329
|
-
" ece.mount(\"#express-checkout-element\");\n" +
|
|
6330
|
-
" var paymentElement = elements.create(\"payment\");\n" +
|
|
6331
|
-
" paymentElement.mount(\"#payment-element\");\n" +
|
|
6332
|
-
" document.getElementById(\"submit\").addEventListener(\"click\", function () { confirm(); });\n" +
|
|
6333
|
-
" })();\n" +
|
|
6334
|
-
" </script>\n" +
|
|
6519
|
+
" RAW_PAY_SCRIPT\n" +
|
|
6335
6520
|
"</section>\n";
|
|
6336
6521
|
|
|
6337
6522
|
function renderPayPage(opts) {
|
|
@@ -6341,13 +6526,10 @@ function renderPayPage(opts) {
|
|
|
6341
6526
|
var shopName = opts.shop_name || "blamejs.shop";
|
|
6342
6527
|
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
6343
6528
|
var grandTotal = pricing.format(opts.order.grand_total_minor, opts.order.currency);
|
|
6344
|
-
//
|
|
6345
|
-
//
|
|
6346
|
-
//
|
|
6347
|
-
//
|
|
6348
|
-
// concatenation possible at this layer.
|
|
6349
|
-
var pkJson = JSON.stringify(opts.publishable_key);
|
|
6350
|
-
var secretJson = JSON.stringify(opts.client_secret);
|
|
6529
|
+
// The publishable key + per-order client_secret ride HTML-attribute-
|
|
6530
|
+
// escaped `data-*` attributes on the mount div, read by the external
|
|
6531
|
+
// `pay.js` island — no inline `<script>`, no JS-literal interpolation. The
|
|
6532
|
+
// route-scoped CSP set on GET /pay/:order_id admits js.stripe.com.
|
|
6351
6533
|
if (opts.theme) {
|
|
6352
6534
|
return opts.theme.render("pay", {
|
|
6353
6535
|
title: "Pay",
|
|
@@ -6355,18 +6537,21 @@ function renderPayPage(opts) {
|
|
|
6355
6537
|
cart_count: cartCount,
|
|
6356
6538
|
order_id: opts.order.id,
|
|
6357
6539
|
grand_total: grandTotal,
|
|
6358
|
-
|
|
6359
|
-
|
|
6540
|
+
pk: opts.publishable_key,
|
|
6541
|
+
client_secret: opts.client_secret,
|
|
6542
|
+
pay_script: opts.theme.assetUrl("js/pay.js"),
|
|
6360
6543
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
6361
6544
|
});
|
|
6362
6545
|
}
|
|
6546
|
+
// {{pk}} / {{client_secret}} / {{order_id}} go through _render's
|
|
6547
|
+
// escape-by-default path (HTML-attribute-escaped), NOT the old RAW_*
|
|
6548
|
+
// JS-literal splice — they're attribute values now, not script literals.
|
|
6363
6549
|
var body = _render(PAY_PAGE, {
|
|
6364
6550
|
order_id: opts.order.id,
|
|
6365
6551
|
grand_total: grandTotal,
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
}).replace("
|
|
6369
|
-
.replace("RAW_SECRET", secretJson);
|
|
6552
|
+
pk: opts.publishable_key,
|
|
6553
|
+
client_secret: opts.client_secret,
|
|
6554
|
+
}).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
|
|
6370
6555
|
return _wrap({
|
|
6371
6556
|
title: "Pay",
|
|
6372
6557
|
shop_name: shopName,
|
|
@@ -7906,6 +8091,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
7906
8091
|
" RAW_LOGIN_ERROR\n" +
|
|
7907
8092
|
" <form id=\"login-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
|
|
7908
8093
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
8094
|
+
" RAW_LOGIN_CAPTCHA\n" +
|
|
7909
8095
|
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Sign in with passkey</button></div>\n" +
|
|
7910
8096
|
" <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
|
|
7911
8097
|
" </form>\n" +
|
|
@@ -7941,6 +8127,11 @@ function renderAccountLogin(opts) {
|
|
|
7941
8127
|
var body = ACCOUNT_LOGIN_PAGE
|
|
7942
8128
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
7943
8129
|
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
8130
|
+
// Login captcha is gated separately (CAPTCHA_GATE_LOGIN): the widget
|
|
8131
|
+
// renders only when the operator has a provider active AND opted login
|
|
8132
|
+
// in (opts.captcha_kind set). For the JSON ceremony the token is read by
|
|
8133
|
+
// passkey-login.js via window.__captchaToken() — no hidden field.
|
|
8134
|
+
.replace("RAW_LOGIN_CAPTCHA", _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, false))
|
|
7944
8135
|
.replace("RAW_LOGIN_SCRIPT", _islandScript("passkey-login.js"));
|
|
7945
8136
|
return _wrap({
|
|
7946
8137
|
title: "Sign in",
|
|
@@ -7960,6 +8151,7 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
7960
8151
|
" <form id=\"reg-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
|
|
7961
8152
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
7962
8153
|
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span><input type=\"text\" name=\"display_name\" id=\"display_name\" maxlength=\"128\" required autocomplete=\"name\"></label></div>\n" +
|
|
8154
|
+
" RAW_REGISTER_CAPTCHA\n" +
|
|
7963
8155
|
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Create account & enroll passkey</button></div>\n" +
|
|
7964
8156
|
" <p id=\"reg-message\" class=\"auth-form__message\"></p>\n" +
|
|
7965
8157
|
" </form>\n" +
|
|
@@ -7970,12 +8162,19 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
7970
8162
|
|
|
7971
8163
|
function renderAccountRegister(opts) {
|
|
7972
8164
|
opts = opts || {};
|
|
8165
|
+
// Signup captcha renders whenever the operator has an active provider
|
|
8166
|
+
// (opts.captcha_kind + opts.captcha_public_key). The token is read by
|
|
8167
|
+
// passkey-register.js via window.__captchaToken() — no hidden field on
|
|
8168
|
+
// the JSON ceremony. Absent a provider this is "" (unconfigured no-op).
|
|
8169
|
+
var body = ACCOUNT_REGISTER_PAGE
|
|
8170
|
+
.replace("RAW_REGISTER_CAPTCHA", _captchaWidgetBlock(opts.captcha_kind, opts.captcha_public_key, false))
|
|
8171
|
+
.replace("RAW_REGISTER_SCRIPT", _islandScript("passkey-register.js"));
|
|
7973
8172
|
return _wrap({
|
|
7974
8173
|
title: "Create account",
|
|
7975
8174
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
7976
8175
|
cart_count: opts.cart_count,
|
|
7977
8176
|
theme_css: opts.theme_css,
|
|
7978
|
-
body:
|
|
8177
|
+
body: body,
|
|
7979
8178
|
});
|
|
7980
8179
|
}
|
|
7981
8180
|
|
|
@@ -8727,6 +8926,89 @@ function mount(router, deps) {
|
|
|
8727
8926
|
// launch flow converts the reservation into a Stripe-gated order).
|
|
8728
8927
|
var preorder = deps.preorder || null;
|
|
8729
8928
|
|
|
8929
|
+
// CAPTCHA gate (bot challenge at signup / login / checkout). Active ONLY
|
|
8930
|
+
// when the operator has registered a provider AND set CAPTCHA_PROVIDER_SLUG
|
|
8931
|
+
// (server.js resolves the provider row at boot into captchaKind +
|
|
8932
|
+
// captchaPublicKey). Absent any of that, every helper below is a no-op:
|
|
8933
|
+
// no widget renders, no token is verified, and the flows behave EXACTLY as
|
|
8934
|
+
// an unconfigured store. Signup + checkout challenge whenever a provider is
|
|
8935
|
+
// active; login challenges only when the operator also opts in
|
|
8936
|
+
// (deps.captchaLoginEnabled, the CAPTCHA_GATE_LOGIN flag) — passkey login
|
|
8937
|
+
// is already phishing-resistant.
|
|
8938
|
+
var captchaGate = deps.captchaGate || null;
|
|
8939
|
+
var captchaSlug = deps.captchaProviderSlug || "";
|
|
8940
|
+
var captchaKind = deps.captchaKind || "";
|
|
8941
|
+
var captchaPubKey = deps.captchaPublicKey || "";
|
|
8942
|
+
var captchaVerify = (typeof deps.captchaVerify === "function") ? deps.captchaVerify : null;
|
|
8943
|
+
var captchaActive = !!(captchaGate && captchaSlug && captchaKind && captchaPubKey && captchaVerify);
|
|
8944
|
+
var captchaLoginOn = captchaActive && !!deps.captchaLoginEnabled;
|
|
8945
|
+
|
|
8946
|
+
// The CSP_HOSTS key (turnstile / hcaptcha / recaptcha) that admits the
|
|
8947
|
+
// active provider's SDK host on the scoped CSP, or null when inactive.
|
|
8948
|
+
var captchaCspKey = captchaActive ? _captchaCspKey(captchaKind) : null;
|
|
8949
|
+
|
|
8950
|
+
// Verify a submitted captcha token for a gate, recording the outcome.
|
|
8951
|
+
// Returns { ok, status, message } — ok=true means proceed; ok=false means
|
|
8952
|
+
// refuse with the given status + clean message. A no-op pass-through
|
|
8953
|
+
// (ok=true) when the gate is inactive for this flow, so callers wrap the
|
|
8954
|
+
// verify unconditionally and the unconfigured store never changes. The
|
|
8955
|
+
// session/ip hashing is the primitive's expectation (pre-hashed values);
|
|
8956
|
+
// we pass the raw session id (recordOutcome hashes it) + a hashed ip.
|
|
8957
|
+
async function _verifyCaptcha(req, gate, token) {
|
|
8958
|
+
if (!captchaActive) return { ok: true };
|
|
8959
|
+
// Inactive for login unless the operator opted in.
|
|
8960
|
+
if (gate === "other" && !captchaLoginOn) return { ok: true };
|
|
8961
|
+
var raw = (typeof token === "string") ? token.trim() : "";
|
|
8962
|
+
// Defensive request-shape reader: a missing / blank token fails CLOSED at
|
|
8963
|
+
// the caller — never hand a placeholder to the provider (whose acceptance
|
|
8964
|
+
// we don't control). Recorded as a failed outcome, refused with a clean
|
|
8965
|
+
// message. This is independent of provider behavior.
|
|
8966
|
+
var outcome;
|
|
8967
|
+
if (!raw) {
|
|
8968
|
+
outcome = { ok: false, score: null, reasons: ["missing_token"] };
|
|
8969
|
+
} else {
|
|
8970
|
+
try {
|
|
8971
|
+
outcome = await captchaGate.verifyToken({
|
|
8972
|
+
provider_slug: captchaSlug,
|
|
8973
|
+
token: raw,
|
|
8974
|
+
verify: captchaVerify,
|
|
8975
|
+
});
|
|
8976
|
+
} catch (_e) {
|
|
8977
|
+
// A bad provider-slug shape / config fault → "unavailable", same
|
|
8978
|
+
// posture as vault/not-initialized (503) rather than a raw leak.
|
|
8979
|
+
return { ok: false, status: 503, message: "Verification is temporarily unavailable. Please try again." };
|
|
8980
|
+
}
|
|
8981
|
+
}
|
|
8982
|
+
// Best-effort audit row; never blocks the request on a write failure.
|
|
8983
|
+
try {
|
|
8984
|
+
var ipHash = b.crypto.namespaceHash("captcha-ip", securityMiddleware.clientKey(req));
|
|
8985
|
+
await captchaGate.recordOutcome({
|
|
8986
|
+
provider_slug: captchaSlug,
|
|
8987
|
+
gate: gate,
|
|
8988
|
+
ok: !!outcome.ok,
|
|
8989
|
+
score: outcome.score == null ? undefined : outcome.score,
|
|
8990
|
+
ip_hash: ipHash,
|
|
8991
|
+
});
|
|
8992
|
+
} catch (_re) { /* drop-silent — audit write must never break the flow */ }
|
|
8993
|
+
if (!outcome.ok) {
|
|
8994
|
+
return { ok: false, status: 400, message: "Please complete the verification challenge and try again." };
|
|
8995
|
+
}
|
|
8996
|
+
return { ok: true };
|
|
8997
|
+
}
|
|
8998
|
+
|
|
8999
|
+
// Set the route-scoped CSP that admits the active captcha provider host on
|
|
9000
|
+
// the auth pages — ONLY when the gate is active for that flow. When
|
|
9001
|
+
// inactive the strict default app-level CSP stays (no setHeader), so the
|
|
9002
|
+
// unconfigured store's auth pages are unchanged. `flow` is "signup" |
|
|
9003
|
+
// "login": login only admits the host when login is opted in.
|
|
9004
|
+
function _setAuthCaptchaCsp(res, flow) {
|
|
9005
|
+
if (!captchaCspKey) return;
|
|
9006
|
+
if (flow === "login" && !captchaLoginOn) return;
|
|
9007
|
+
if (res && res.setHeader) {
|
|
9008
|
+
res.setHeader("content-security-policy", securityMiddleware.scopedCsp([captchaCspKey]));
|
|
9009
|
+
}
|
|
9010
|
+
}
|
|
9011
|
+
|
|
8730
9012
|
// Active cookie-consent policy version. `_liveConsentPolicy()` reads it
|
|
8731
9013
|
// from the consent primitive per request so a runtime `policyVersion`
|
|
8732
9014
|
// bump takes effect on the gate immediately, and refreshes the module
|
|
@@ -9028,20 +9310,64 @@ function mount(router, deps) {
|
|
|
9028
9310
|
" </form>\n";
|
|
9029
9311
|
}
|
|
9030
9312
|
|
|
9313
|
+
// hreflang `<link rel="alternate">` block for a URL-shaped locale policy.
|
|
9314
|
+
// The byte-identical twin of the edge's `chrome-i18n.js#alternateLinks` —
|
|
9315
|
+
// an edge↔container parity test pins the two so they never drift. Returns
|
|
9316
|
+
// "" for a non-URL strategy (cookie / accept-language / single-locale) or
|
|
9317
|
+
// a malformed host/path, so most stores emit nothing. Synchronous so the
|
|
9318
|
+
// locale middleware can seed it onto the ALS store within one tick (an
|
|
9319
|
+
// `await` would scope the store to the awaited frame, not the request).
|
|
9320
|
+
function _alternateLinksHtml(host, path) {
|
|
9321
|
+
var strategy = localeOptions.strategy;
|
|
9322
|
+
if (strategy !== "url_prefix" && strategy !== "subdomain") return "";
|
|
9323
|
+
if (supportedTags.length < 2) return "";
|
|
9324
|
+
if (typeof defaultLocale !== "string" || !_HREFLANG_TAG_RE.test(defaultLocale)) return "";
|
|
9325
|
+
if (typeof host !== "string" || typeof path !== "string") return "";
|
|
9326
|
+
var h = host.toLowerCase();
|
|
9327
|
+
var colon = h.indexOf(":");
|
|
9328
|
+
if (colon !== -1) h = h.slice(0, colon);
|
|
9329
|
+
if (!h.length || h.length > 253 || !_HREFLANG_HOST_RE.test(h)) return "";
|
|
9330
|
+
if (!path.length || path.length > 2048 || path.charAt(0) !== "/") return "";
|
|
9331
|
+
if (/[\x00-\x1f\x7f]/.test(path)) return "";
|
|
9332
|
+
var esc = b.template.escapeHtml;
|
|
9333
|
+
var out = "";
|
|
9334
|
+
for (var i = 0; i < supportedTags.length; i += 1) {
|
|
9335
|
+
var tag = supportedTags[i];
|
|
9336
|
+
if (typeof tag !== "string" || !_HREFLANG_TAG_RE.test(tag)) continue;
|
|
9337
|
+
var href = _canonicalForLocaleUrl(h, path, tag, strategy);
|
|
9338
|
+
if (href == null) continue;
|
|
9339
|
+
out += " <link rel=\"alternate\" hreflang=\"" + esc(tag) +
|
|
9340
|
+
"\" href=\"" + esc(href) + "\">\n";
|
|
9341
|
+
}
|
|
9342
|
+
if (!out) return "";
|
|
9343
|
+
var xDefault = _canonicalForLocaleUrl(h, path, defaultLocale, strategy);
|
|
9344
|
+
if (xDefault != null) {
|
|
9345
|
+
out += " <link rel=\"alternate\" hreflang=\"x-default\" href=\"" +
|
|
9346
|
+
esc(xDefault) + "\">\n";
|
|
9347
|
+
}
|
|
9348
|
+
return out;
|
|
9349
|
+
}
|
|
9350
|
+
|
|
9031
9351
|
// The per-request locale context the layout reads via the async-local
|
|
9032
9352
|
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
9033
|
-
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html
|
|
9034
|
-
// Never throws — any failure falls back to the
|
|
9353
|
+
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html,
|
|
9354
|
+
// alternate_links }. Never throws — any failure falls back to the
|
|
9355
|
+
// default locale.
|
|
9035
9356
|
function _localeCtx(req) {
|
|
9036
9357
|
var locale = defaultLocale;
|
|
9037
9358
|
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
9038
9359
|
var chrome = _chromeFor(locale);
|
|
9360
|
+
var hostHeader = (req.headers && (req.headers.host || req.headers.Host)) || "";
|
|
9361
|
+
var reqPath = req.pathname || (String(req.url || "/").split("?")[0]) || "/";
|
|
9362
|
+
var alternateLinks = "";
|
|
9363
|
+
try { alternateLinks = _alternateLinksHtml(String(hostHeader), reqPath); } catch (_e) { alternateLinks = ""; }
|
|
9039
9364
|
return {
|
|
9040
|
-
locale:
|
|
9041
|
-
lang:
|
|
9042
|
-
dir:
|
|
9043
|
-
chrome:
|
|
9044
|
-
switcher_html:
|
|
9365
|
+
locale: locale,
|
|
9366
|
+
lang: locale,
|
|
9367
|
+
dir: _dirFor(locale),
|
|
9368
|
+
chrome: chrome,
|
|
9369
|
+
switcher_html: _switcherHtml(req, locale, chrome),
|
|
9370
|
+
alternate_links: alternateLinks,
|
|
9045
9371
|
};
|
|
9046
9372
|
}
|
|
9047
9373
|
|
|
@@ -10852,9 +11178,27 @@ function mount(router, deps) {
|
|
|
10852
11178
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
10853
11179
|
prefill: prefill,
|
|
10854
11180
|
inline_error: inlineError || null,
|
|
11181
|
+
// CAPTCHA widget props — set only when a provider is active, so the
|
|
11182
|
+
// unconfigured checkout renders no widget (renderCheckoutForm treats
|
|
11183
|
+
// an absent kind/key as no-op).
|
|
11184
|
+
captcha_kind: captchaActive ? captchaKind : null,
|
|
11185
|
+
captcha_public_key: captchaActive ? captchaPubKey : null,
|
|
10855
11186
|
};
|
|
10856
11187
|
}
|
|
10857
11188
|
|
|
11189
|
+
// Route-scoped CSP for the checkout pages: always admits Stripe; adds
|
|
11190
|
+
// PayPal when configured (the block only renders then) and the active
|
|
11191
|
+
// CAPTCHA provider host when the gate is active. Set per response so the
|
|
11192
|
+
// app-level strict CSP governs every other route untouched. When nothing
|
|
11193
|
+
// beyond Stripe applies it still scopes to ["stripe"] (the pay step needs
|
|
11194
|
+
// it). Computed from the SAME conditions that gate what the page renders.
|
|
11195
|
+
function _setCheckoutCsp(res) {
|
|
11196
|
+
var keys = ["stripe"];
|
|
11197
|
+
if (deps.paypal && deps.paypal_client_id) keys.push("paypal");
|
|
11198
|
+
if (captchaActive && captchaCspKey) keys.push(captchaCspKey);
|
|
11199
|
+
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(keys));
|
|
11200
|
+
}
|
|
11201
|
+
|
|
10858
11202
|
router.get("/checkout", async function (req, res) {
|
|
10859
11203
|
var sid = _readSidCookie(req);
|
|
10860
11204
|
if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
|
|
@@ -10872,6 +11216,7 @@ function mount(router, deps) {
|
|
|
10872
11216
|
// cart page) so the summary's per-line totals + subtotal match what
|
|
10873
11217
|
// the shopper saw on /cart.
|
|
10874
11218
|
var lines = await _repriceCartLines(rawLines);
|
|
11219
|
+
_setCheckoutCsp(res);
|
|
10875
11220
|
_send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
|
|
10876
11221
|
});
|
|
10877
11222
|
|
|
@@ -10900,6 +11245,27 @@ function mount(router, deps) {
|
|
|
10900
11245
|
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
10901
11246
|
return res.end ? res.end() : res.send("");
|
|
10902
11247
|
}
|
|
11248
|
+
// CAPTCHA gate (no-op unless a provider is active for checkout). Verify
|
|
11249
|
+
// BEFORE the cart is converted — a failed challenge re-renders the form
|
|
11250
|
+
// with an inline correction, never starts the order. The form field is
|
|
11251
|
+
// `captcha_token` (rendered inside the checkout form).
|
|
11252
|
+
var coCaptcha = await _verifyCaptcha(req, "checkout_coupon", body.captcha_token);
|
|
11253
|
+
if (!coCaptcha.ok) {
|
|
11254
|
+
try {
|
|
11255
|
+
var capLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
11256
|
+
if (capLines.length && coCaptcha.status === 400) {
|
|
11257
|
+
_setCheckoutCsp(res);
|
|
11258
|
+
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, capLines, coCaptcha.message)));
|
|
11259
|
+
}
|
|
11260
|
+
} catch (_ce) { /* fall through to the styled error page */ }
|
|
11261
|
+
return _send(res, coCaptcha.status || 400, renderCheckoutError({
|
|
11262
|
+
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
11263
|
+
title_text: "Couldn't verify your request",
|
|
11264
|
+
reason: coCaptcha.message,
|
|
11265
|
+
back_href: "/checkout", back_label: "Back to checkout",
|
|
11266
|
+
secondary_href: "/cart", secondary_label: "Back to cart",
|
|
11267
|
+
}));
|
|
11268
|
+
}
|
|
10903
11269
|
var shipTo = _shipToFromBody(body);
|
|
10904
11270
|
try {
|
|
10905
11271
|
// default_shipping_id may be a literal string or an
|
|
@@ -10958,6 +11324,7 @@ function mount(router, deps) {
|
|
|
10958
11324
|
// the summary against it (exact, not estimated) so the inline
|
|
10959
11325
|
// re-render shows the same total the confirm path computed.
|
|
10960
11326
|
var confirmedTo = (shipTo && /^[A-Z]{2}$/.test(shipTo.country)) ? shipTo : null;
|
|
11327
|
+
_setCheckoutCsp(res);
|
|
10961
11328
|
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg, confirmedTo)));
|
|
10962
11329
|
}
|
|
10963
11330
|
} catch (_re) { /* fall through to the styled error page */ }
|
|
@@ -10998,6 +11365,11 @@ function mount(router, deps) {
|
|
|
10998
11365
|
if (!sid) return _json(400, { error: "no-session" });
|
|
10999
11366
|
var c = await deps.cart.bySession(sid);
|
|
11000
11367
|
if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
|
|
11368
|
+
// CAPTCHA gate — the same challenge POST /checkout runs (gate
|
|
11369
|
+
// "checkout_coupon"). Without it, a bot could bypass the card-checkout
|
|
11370
|
+
// challenge via the PayPal path. No-op when no provider is configured.
|
|
11371
|
+
var ppCaptcha = await _verifyCaptcha(req, "checkout_coupon", body.captcha_token);
|
|
11372
|
+
if (!ppCaptcha.ok) return _json(ppCaptcha.status || 400, { error: ppCaptcha.message || "captcha-required" });
|
|
11001
11373
|
var shipTo = _shipToFromBody(body);
|
|
11002
11374
|
try {
|
|
11003
11375
|
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
@@ -11075,6 +11447,12 @@ function mount(router, deps) {
|
|
|
11075
11447
|
res.status(503);
|
|
11076
11448
|
return res.end ? res.end("Stripe publishable key not configured") : res.send("Stripe publishable key not configured");
|
|
11077
11449
|
}
|
|
11450
|
+
// Route-scoped CSP that admits js.stripe.com on script/connect/frame-src
|
|
11451
|
+
// (and Trusted Types stays on) so the Stripe SDK + the same-origin
|
|
11452
|
+
// pay.js island load — without relaxing the app-level strict CSP that
|
|
11453
|
+
// governs every OTHER route. setHeader OVERWRITES the app-level header
|
|
11454
|
+
// for this response only.
|
|
11455
|
+
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
|
|
11078
11456
|
_send(res, 200, renderPayPage({
|
|
11079
11457
|
order: o,
|
|
11080
11458
|
client_secret: clientSecret,
|
|
@@ -11372,23 +11750,44 @@ function mount(router, deps) {
|
|
|
11372
11750
|
router.get("/account/login", async function (req, res) {
|
|
11373
11751
|
var cartCount = await _cartCountForReq(req);
|
|
11374
11752
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
11753
|
+
// Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
|
|
11754
|
+
// CSP that admits the provider host render only when login is opted in.
|
|
11755
|
+
_setAuthCaptchaCsp(res, "login");
|
|
11375
11756
|
_send(res, 200, renderAccountLogin({
|
|
11376
11757
|
shop_name: shopName,
|
|
11377
11758
|
cart_count: cartCount,
|
|
11378
11759
|
google_enabled: !!deps.oauthGoogle,
|
|
11379
11760
|
apple_enabled: !!deps.oauthApple,
|
|
11380
11761
|
error: url && url.searchParams.get("error"),
|
|
11762
|
+
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
11763
|
+
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
11381
11764
|
}));
|
|
11382
11765
|
});
|
|
11383
11766
|
|
|
11384
11767
|
router.get("/account/register", async function (req, res) {
|
|
11385
11768
|
var cartCount = await _cartCountForReq(req);
|
|
11386
|
-
|
|
11769
|
+
// Signup captcha renders whenever a provider is active; the scoped CSP
|
|
11770
|
+
// admits the provider host only then (no setHeader otherwise).
|
|
11771
|
+
_setAuthCaptchaCsp(res, "signup");
|
|
11772
|
+
_send(res, 200, renderAccountRegister({
|
|
11773
|
+
shop_name: shopName,
|
|
11774
|
+
cart_count: cartCount,
|
|
11775
|
+
captcha_kind: captchaActive ? captchaKind : null,
|
|
11776
|
+
captcha_public_key: captchaActive ? captchaPubKey : null,
|
|
11777
|
+
}));
|
|
11387
11778
|
});
|
|
11388
11779
|
|
|
11389
11780
|
router.post("/account/passkey/register-begin", async function (req, res) {
|
|
11390
11781
|
try {
|
|
11391
11782
|
var body = _readJsonBody(req);
|
|
11783
|
+
// CAPTCHA gate (no-op unless a provider is active). Verify the token
|
|
11784
|
+
// BEFORE creating the customer / minting a challenge — a failed
|
|
11785
|
+
// challenge refuses with a clean message and writes no customer row.
|
|
11786
|
+
var regCaptcha = await _verifyCaptcha(req, "signup", body.captcha_token);
|
|
11787
|
+
if (!regCaptcha.ok) {
|
|
11788
|
+
res.status(regCaptcha.status || 400);
|
|
11789
|
+
return res.end ? res.end(regCaptcha.message) : res.send(regCaptcha.message);
|
|
11790
|
+
}
|
|
11392
11791
|
// Persist the customer row up-front. The address is the
|
|
11393
11792
|
// registration's natural identifier — if enrollment fails
|
|
11394
11793
|
// the customer can re-attempt with the same email; the
|
|
@@ -11504,6 +11903,15 @@ function mount(router, deps) {
|
|
|
11504
11903
|
router.post("/account/passkey/login-begin", async function (req, res) {
|
|
11505
11904
|
try {
|
|
11506
11905
|
var body = _readJsonBody(req);
|
|
11906
|
+
// CAPTCHA gate (no-op unless a provider is active AND login is opted
|
|
11907
|
+
// in via CAPTCHA_GATE_LOGIN — passkey login is already phishing-
|
|
11908
|
+
// resistant). Recorded under gate "other" (the GATES enum has no
|
|
11909
|
+
// dedicated login member). Verified before any challenge is minted.
|
|
11910
|
+
var loginCaptcha = await _verifyCaptcha(req, "other", body.captcha_token);
|
|
11911
|
+
if (!loginCaptcha.ok) {
|
|
11912
|
+
res.status(loginCaptcha.status || 400);
|
|
11913
|
+
return res.end ? res.end(loginCaptcha.message) : res.send(loginCaptcha.message);
|
|
11914
|
+
}
|
|
11507
11915
|
var hash = deps.customers.hashEmail(body.email);
|
|
11508
11916
|
var customer = await deps.customers.byEmailHash(hash);
|
|
11509
11917
|
// Even when the customer doesn't exist, return a valid-shaped
|
|
@@ -15816,17 +16224,23 @@ function mount(router, deps) {
|
|
|
15816
16224
|
// robots.txt — minimal crawl policy. Allow everything except
|
|
15817
16225
|
// the admin API + cart + account + checkout / pay / orders (these
|
|
15818
16226
|
// are session-scoped or operator-only, no crawl value), and
|
|
15819
|
-
// point at the sitemap.
|
|
15820
|
-
//
|
|
15821
|
-
//
|
|
16227
|
+
// point at the sitemap. When the operator has defined per-bot rules
|
|
16228
|
+
// via robotsConfig, those drive the body instead; absent any rules
|
|
16229
|
+
// the hardcoded Disallow set is kept (robotsConfig's empty-table
|
|
16230
|
+
// branch emits `Allow: /` only, which would silently drop the
|
|
16231
|
+
// /admin disallow on a fresh deploy). Operators with stricter
|
|
16232
|
+
// requirements also replace the file at the same path via R2 (the
|
|
16233
|
+
// Worker's static-asset bridge serves it ahead of this route if a
|
|
15822
16234
|
// `robots.txt` key exists in the bucket).
|
|
15823
|
-
router.get("/robots.txt", function (req, res) {
|
|
16235
|
+
router.get("/robots.txt", async function (req, res) {
|
|
15824
16236
|
res.status(200);
|
|
15825
16237
|
res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
15826
16238
|
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
15827
16239
|
var hostHeader = req.headers && (req.headers.host || req.headers.Host) || "";
|
|
15828
16240
|
var origin = hostHeader ? ("https://" + hostHeader) : "";
|
|
15829
|
-
|
|
16241
|
+
// The shipped fallback — the same Disallow set the edge robots.txt
|
|
16242
|
+
// emits, kept as the fresh-deploy default.
|
|
16243
|
+
var fallback =
|
|
15830
16244
|
"User-agent: *\n" +
|
|
15831
16245
|
"Allow: /\n" +
|
|
15832
16246
|
"Disallow: /admin\n" +
|
|
@@ -15836,6 +16250,57 @@ function mount(router, deps) {
|
|
|
15836
16250
|
"Disallow: /orders/\n" +
|
|
15837
16251
|
"Disallow: /account\n" +
|
|
15838
16252
|
(origin ? ("Sitemap: " + origin + "/sitemap.xml\n") : "");
|
|
16253
|
+
var body = fallback;
|
|
16254
|
+
// robotsConfig.render THROWS on a bad origin (no Host header) / bad
|
|
16255
|
+
// state; the route must never let that reach the response. Only honor
|
|
16256
|
+
// the operator's config when it actually carries rules — an empty
|
|
16257
|
+
// robots_rules table renders `Allow: /` only, weaker than the
|
|
16258
|
+
// hardcoded Disallow set. A read/render failure degrades to the
|
|
16259
|
+
// shipped fallback (drop-to-default), never a 500.
|
|
16260
|
+
if (deps.robotsConfig && origin) {
|
|
16261
|
+
try {
|
|
16262
|
+
var rules = await deps.robotsConfig.listRules({});
|
|
16263
|
+
if (Array.isArray(rules) && rules.length > 0) {
|
|
16264
|
+
body = await deps.robotsConfig.render({ origin_url: origin });
|
|
16265
|
+
}
|
|
16266
|
+
} catch (_e) { body = fallback; }
|
|
16267
|
+
}
|
|
16268
|
+
res.end ? res.end(body) : res.send(body);
|
|
16269
|
+
});
|
|
16270
|
+
|
|
16271
|
+
// PWA web app manifest — served at /manifest.webmanifest (the
|
|
16272
|
+
// `<link rel="manifest">` every layout carries). When the operator has an
|
|
16273
|
+
// active pwaManifest row, its bytes serve; absent one (or on any read /
|
|
16274
|
+
// render failure — renderManifestJson THROWS PWA_NO_ACTIVE_MANIFEST when
|
|
16275
|
+
// no row is active) the shipped default serves (drop-to-default), so the
|
|
16276
|
+
// manifest link never 404s. The default bytes are byte-identical to the
|
|
16277
|
+
// edge's, so a cached edge read and a container read agree.
|
|
16278
|
+
router.get("/manifest.webmanifest", async function (req, res) {
|
|
16279
|
+
var body = PWA_DEFAULT_MANIFEST;
|
|
16280
|
+
if (deps.pwaManifest && typeof deps.pwaManifest.renderManifestJson === "function") {
|
|
16281
|
+
try { body = await deps.pwaManifest.renderManifestJson(); }
|
|
16282
|
+
catch (_e) { body = PWA_DEFAULT_MANIFEST; }
|
|
16283
|
+
}
|
|
16284
|
+
res.status(200);
|
|
16285
|
+
res.setHeader && res.setHeader("content-type", "application/manifest+json; charset=utf-8");
|
|
16286
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
16287
|
+
res.end ? res.end(body) : res.send(body);
|
|
16288
|
+
});
|
|
16289
|
+
|
|
16290
|
+
// PWA service worker — served at /sw.js. Same drop-to-default discipline
|
|
16291
|
+
// as the manifest: the operator's active SW config serves when present,
|
|
16292
|
+
// else the shipped pass-through default (renderServiceWorkerJs THROWS
|
|
16293
|
+
// PWA_NO_ACTIVE_SW when none is active). Served same-origin under the
|
|
16294
|
+
// strict `script-src 'self'` CSP, so no CSP change is needed.
|
|
16295
|
+
router.get("/sw.js", async function (req, res) {
|
|
16296
|
+
var body = PWA_DEFAULT_SW;
|
|
16297
|
+
if (deps.pwaManifest && typeof deps.pwaManifest.renderServiceWorkerJs === "function") {
|
|
16298
|
+
try { body = await deps.pwaManifest.renderServiceWorkerJs(); }
|
|
16299
|
+
catch (_e) { body = PWA_DEFAULT_SW; }
|
|
16300
|
+
}
|
|
16301
|
+
res.status(200);
|
|
16302
|
+
res.setHeader && res.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
16303
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=3600");
|
|
15839
16304
|
res.end ? res.end(body) : res.send(body);
|
|
15840
16305
|
});
|
|
15841
16306
|
|
|
@@ -15859,17 +16324,29 @@ function mount(router, deps) {
|
|
|
15859
16324
|
urls.push({ loc: origin + "/privacy", changefreq: "yearly", priority: "0.3" });
|
|
15860
16325
|
urls.push({ loc: origin + "/terms", changefreq: "yearly", priority: "0.3" });
|
|
15861
16326
|
try {
|
|
15862
|
-
|
|
15863
|
-
|
|
15864
|
-
|
|
15865
|
-
|
|
15866
|
-
|
|
15867
|
-
|
|
15868
|
-
|
|
15869
|
-
|
|
15870
|
-
|
|
15871
|
-
});
|
|
15872
|
-
|
|
16327
|
+
// Page through the catalog via the cursor — `products.list` caps a
|
|
16328
|
+
// single page at the catalog's MAX_LIMIT (200), so a single
|
|
16329
|
+
// `limit: 1000` call would throw and silently drop EVERY product from
|
|
16330
|
+
// the sitemap. Walk pages until exhausted or the sitemap's per-file
|
|
16331
|
+
// 50k-URL ceiling, matching the edge sitemap's 50k cap.
|
|
16332
|
+
var SITEMAP_PRODUCT_CAP = 50000;
|
|
16333
|
+
var productCursor = null;
|
|
16334
|
+
var productCount = 0;
|
|
16335
|
+
do {
|
|
16336
|
+
var page = await deps.catalog.products.list({ status: "active", limit: 200, cursor: productCursor });
|
|
16337
|
+
for (var i = 0; i < page.rows.length && productCount < SITEMAP_PRODUCT_CAP; i += 1) {
|
|
16338
|
+
var p = page.rows[i];
|
|
16339
|
+
var lastmod = new Date(p.updated_at || p.created_at || Date.now()).toISOString().slice(0, 10);
|
|
16340
|
+
urls.push({
|
|
16341
|
+
loc: origin + "/products/" + p.slug,
|
|
16342
|
+
lastmod: lastmod,
|
|
16343
|
+
changefreq: "weekly",
|
|
16344
|
+
priority: "0.8",
|
|
16345
|
+
});
|
|
16346
|
+
productCount += 1;
|
|
16347
|
+
}
|
|
16348
|
+
productCursor = page.next_cursor;
|
|
16349
|
+
} while (productCursor && productCount < SITEMAP_PRODUCT_CAP);
|
|
15873
16350
|
} catch (_e) { /* drop-silent — catalog unreachable means sitemap drops product rows */ }
|
|
15874
16351
|
// Active category browse pages, when the categoryNavigation primitive
|
|
15875
16352
|
// is wired. `tree({})` returns the full nested active tree (archived
|
|
@@ -15903,6 +16380,22 @@ function mount(router, deps) {
|
|
|
15903
16380
|
}
|
|
15904
16381
|
} catch (_e) { /* drop-silent — collections unreachable drops those rows */ }
|
|
15905
16382
|
}
|
|
16383
|
+
// Published CMS pages (/pages/<slug>), when the storefrontPages
|
|
16384
|
+
// primitive is wired. `monthly` / 0.5 — operator content changes less
|
|
16385
|
+
// often than the catalog; the edge sitemap emits the same values.
|
|
16386
|
+
// Best-effort: a read failure drops the page rows rather than 500-ing.
|
|
16387
|
+
if (deps.storefrontPages && typeof deps.storefrontPages.listPublished === "function") {
|
|
16388
|
+
try {
|
|
16389
|
+
var cmsPages = await deps.storefrontPages.listPublished();
|
|
16390
|
+
for (var pgi = 0; pgi < cmsPages.length; pgi += 1) {
|
|
16391
|
+
var pg = cmsPages[pgi];
|
|
16392
|
+
if (pg && pg.slug) {
|
|
16393
|
+
var pgLast = new Date(pg.updated_at || pg.created_at || Date.now()).toISOString().slice(0, 10);
|
|
16394
|
+
urls.push({ loc: origin + "/pages/" + pg.slug, lastmod: pgLast, changefreq: "monthly", priority: "0.5" });
|
|
16395
|
+
}
|
|
16396
|
+
}
|
|
16397
|
+
} catch (_e) { /* drop-silent — CMS pages unreachable drops those rows */ }
|
|
16398
|
+
}
|
|
15906
16399
|
var body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
15907
16400
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
|
15908
16401
|
for (var u = 0; u < urls.length; u += 1) {
|
package/package.json
CHANGED