@blamejs/blamejs-shop 0.3.50 → 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 CHANGED
@@ -8,6 +8,8 @@ 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
+
11
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.
12
14
 
13
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.50",
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-iJ5mKqQ15N2dSCqvQRsez9SRgESaCX8CJD0mTZSYoQVhtxQz+DGdZPYe2+df/0VF",
30
- "fingerprinted": "js/passkey-login.4482cca420479033.js"
33
+ "integrity": "sha384-AbMxT4s0paFnZsEfAHfcpbS2ZRSmZ9KPnttEBy9SvWErBX3U+LhoeYYN7Nm3Y8AX",
34
+ "fingerprinted": "js/passkey-login.90c13d7c0d8667e2.js"
31
35
  },
32
36
  "js/passkey-register.js": {
33
- "integrity": "sha384-NB0gCF+Bpo6cJYkqfTdcvCMYBzpTJfzz/pYCx6iTykznRWVcBHKqidj+mMIv5qKV",
34
- "fingerprinted": "js/passkey-register.537c8a0a0b70ddec.js"
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
  }
@@ -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/` and `/gift-cards` carry GET reads (PDP, balance page
302
- // render) that a shopper hits freely — only gate the state-changing
303
- // / lookup POSTs there. Login / register / passkey / checkout /
304
- // newsletter / survey are gated on every method (their GETs are the
305
- // form render, which a spray would hammer just the same to harvest a
306
- // fresh token, so the budget covers both).
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
@@ -6287,8 +6287,14 @@ function renderCheckoutForm(opts) {
6287
6287
  var grandTotal = pricing.format(
6288
6288
  dTotals.grand_total_minor == null ? dTotals.subtotal_minor : dTotals.grand_total_minor,
6289
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);
6290
6296
  if (opts.theme) {
6291
- return opts.theme.render("checkout", {
6297
+ var themed = opts.theme.render("checkout", {
6292
6298
  title: "Checkout",
6293
6299
  shop_name: shopName,
6294
6300
  cart_count: lines.length,
@@ -6296,6 +6302,8 @@ function renderCheckoutForm(opts) {
6296
6302
  total: grandTotal,
6297
6303
  asset_css_main: opts.theme.assetUrl("css/main.css"),
6298
6304
  });
6305
+ if (checkoutCaptcha) themed = themed.replace("</form>", checkoutCaptcha + "</form>");
6306
+ return themed;
6299
6307
  }
6300
6308
  // Order-summary line items in the sticky rail — same thumbnail + title
6301
6309
  // pattern as the cart, compact. Formatted in the order's own currency
@@ -6322,6 +6330,15 @@ function renderCheckoutForm(opts) {
6322
6330
  .replace("RAW_INLINE_ERROR", inlineError)
6323
6331
  .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
6324
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
+ }
6325
6342
  // Signed-in customer with a spendable points balance — surface a
6326
6343
  // redeem-at-checkout field. The block is appended as raw HTML (the
6327
6344
  // balance + value are numbers we control, the conversion ratio is the
@@ -6410,35 +6427,78 @@ function renderGiftCardBalance(opts) {
6410
6427
  });
6411
6428
  }
6412
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.
6413
6437
  function _paypalCheckoutBlock(clientId, currency) {
6414
6438
  var esc = b.template.escapeHtml;
6415
6439
  var cid = esc(String(clientId));
6416
6440
  var cur = esc(String(currency || "USD"));
6417
- 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;\">" +
6418
6442
  "<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
6419
6443
  "<div id=\"paypal-button-container\"></div>" +
6420
6444
  "<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
6421
6445
  "</div>" +
6422
6446
  "<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "&currency=" + cur + "&intent=capture\"></script>" +
6423
- "<script>(function(){" +
6424
- "if(!window.paypal){return;}" +
6425
- "var form=document.querySelector('.checkout-page form');" +
6426
- "var errEl=document.getElementById('paypal-error');" +
6427
- "function vals(){var d={};['email','name','line1','line2','city','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
6428
- "function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
6429
- "paypal.Buttons({" +
6430
- "onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
6431
- "createOrder:function(){return fetch('/checkout/paypal/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(vals())}).then(function(r){return r.json();}).then(function(d){if(!d.id){throw new Error(d.error||'create failed');}return d.id;});}," +
6432
- "onApprove:function(data){return fetch('/checkout/paypal/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({paypal_order_id:data.orderID})}).then(function(r){return r.json();}).then(function(d){if(d.redirect){window.location.href=d.redirect;}else{showErr(d.error);}});}," +
6433
- "onError:function(){showErr();}" +
6434
- "}).render('#paypal-button-container');" +
6435
- "})();</script>\n";
6436
- }
6437
-
6438
- // Stripe Elements payment page embeds Stripe.js + a minimal
6439
- // mount block. The publishable key is operator-supplied (env
6440
- // `STRIPE_PUBLISHABLE_KEY` forwarded into the rendered HTML).
6441
- // The client_secret is per-order; never logged, never persisted.
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.
6442
6502
  var PAY_PAGE =
6443
6503
  "<section class=\"pay-page\">\n" +
6444
6504
  " <header class=\"section-head\">\n" +
@@ -6446,7 +6506,7 @@ var PAY_PAGE =
6446
6506
  " <h1 class=\"section-head__title\">Pay {{grand_total}}</h1>\n" +
6447
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" +
6448
6508
  " </header>\n" +
6449
- " <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" +
6450
6510
  " <div id=\"express-checkout\" class=\"pay-card__express\" hidden>\n" +
6451
6511
  " <div id=\"express-checkout-element\"></div>\n" +
6452
6512
  " <div class=\"pay-card__divider\"><span>or pay with card</span></div>\n" +
@@ -6456,35 +6516,7 @@ var PAY_PAGE =
6456
6516
  " <p id=\"payment-message\" class=\"pay-card__message\"></p>\n" +
6457
6517
  " </div>\n" +
6458
6518
  " <script src=\"https://js.stripe.com/v3/\"></script>\n" +
6459
- " <script>\n" +
6460
- " (function () {\n" +
6461
- " var stripe = Stripe({{pk_json}});\n" +
6462
- " var elements = stripe.elements({ clientSecret: {{client_secret_json}}, appearance: { theme: \"stripe\" } });\n" +
6463
- " var returnUrl = window.location.origin + \"/orders/{{order_id}}\";\n" +
6464
- " var message = document.getElementById(\"payment-message\");\n" +
6465
- " function confirm() {\n" +
6466
- " message.textContent = \"Processing...\";\n" +
6467
- " return stripe.confirmPayment({ elements: elements, confirmParams: { return_url: returnUrl } }).then(function (result) {\n" +
6468
- " if (result.error) { message.textContent = result.error.message || \"Payment failed.\"; }\n" +
6469
- " });\n" +
6470
- " }\n" +
6471
- " // Express Checkout Element — renders Apple Pay / Google Pay /\n" +
6472
- " // Link wallet buttons when the device + the shop's registered\n" +
6473
- " // payment-method domain make them available. It confirms the\n" +
6474
- " // same PaymentIntent as the card form, so the webhook + order\n" +
6475
- " // FSM are identical. Hidden until Stripe reports an available\n" +
6476
- " // wallet so the divider never sits over an empty box.\n" +
6477
- " var ece = elements.create(\"expressCheckout\");\n" +
6478
- " ece.on(\"ready\", function (ev) {\n" +
6479
- " if (ev && ev.availablePaymentMethods) { document.getElementById(\"express-checkout\").hidden = false; }\n" +
6480
- " });\n" +
6481
- " ece.on(\"confirm\", function () { confirm(); });\n" +
6482
- " ece.mount(\"#express-checkout-element\");\n" +
6483
- " var paymentElement = elements.create(\"payment\");\n" +
6484
- " paymentElement.mount(\"#payment-element\");\n" +
6485
- " document.getElementById(\"submit\").addEventListener(\"click\", function () { confirm(); });\n" +
6486
- " })();\n" +
6487
- " </script>\n" +
6519
+ " RAW_PAY_SCRIPT\n" +
6488
6520
  "</section>\n";
6489
6521
 
6490
6522
  function renderPayPage(opts) {
@@ -6494,13 +6526,10 @@ function renderPayPage(opts) {
6494
6526
  var shopName = opts.shop_name || "blamejs.shop";
6495
6527
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
6496
6528
  var grandTotal = pricing.format(opts.order.grand_total_minor, opts.order.currency);
6497
- // Stripe.js and client_secret values must be JSON-encoded so the
6498
- // template engine treats them as raw expressions (`{{{ }}}` /
6499
- // post-render replace) rather than HTML-escaping the quotes. The
6500
- // values are otherwise opaque to the renderer — no string
6501
- // concatenation possible at this layer.
6502
- var pkJson = JSON.stringify(opts.publishable_key);
6503
- 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.
6504
6533
  if (opts.theme) {
6505
6534
  return opts.theme.render("pay", {
6506
6535
  title: "Pay",
@@ -6508,18 +6537,21 @@ function renderPayPage(opts) {
6508
6537
  cart_count: cartCount,
6509
6538
  order_id: opts.order.id,
6510
6539
  grand_total: grandTotal,
6511
- pk_json: pkJson,
6512
- client_secret_json: secretJson,
6540
+ pk: opts.publishable_key,
6541
+ client_secret: opts.client_secret,
6542
+ pay_script: opts.theme.assetUrl("js/pay.js"),
6513
6543
  asset_css_main: opts.theme.assetUrl("css/main.css"),
6514
6544
  });
6515
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.
6516
6549
  var body = _render(PAY_PAGE, {
6517
6550
  order_id: opts.order.id,
6518
6551
  grand_total: grandTotal,
6519
- pk_json: "RAW_PK",
6520
- client_secret_json: "RAW_SECRET",
6521
- }).replace("RAW_PK", pkJson)
6522
- .replace("RAW_SECRET", secretJson);
6552
+ pk: opts.publishable_key,
6553
+ client_secret: opts.client_secret,
6554
+ }).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
6523
6555
  return _wrap({
6524
6556
  title: "Pay",
6525
6557
  shop_name: shopName,
@@ -8059,6 +8091,7 @@ var ACCOUNT_LOGIN_PAGE =
8059
8091
  " RAW_LOGIN_ERROR\n" +
8060
8092
  " <form id=\"login-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
8061
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" +
8062
8095
  " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Sign in with passkey</button></div>\n" +
8063
8096
  " <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
8064
8097
  " </form>\n" +
@@ -8094,6 +8127,11 @@ function renderAccountLogin(opts) {
8094
8127
  var body = ACCOUNT_LOGIN_PAGE
8095
8128
  .replace("RAW_LOGIN_OAUTH", oauthHtml)
8096
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))
8097
8135
  .replace("RAW_LOGIN_SCRIPT", _islandScript("passkey-login.js"));
8098
8136
  return _wrap({
8099
8137
  title: "Sign in",
@@ -8113,6 +8151,7 @@ var ACCOUNT_REGISTER_PAGE =
8113
8151
  " <form id=\"reg-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
8114
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" +
8115
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" +
8116
8155
  " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Create account &amp; enroll passkey</button></div>\n" +
8117
8156
  " <p id=\"reg-message\" class=\"auth-form__message\"></p>\n" +
8118
8157
  " </form>\n" +
@@ -8123,12 +8162,19 @@ var ACCOUNT_REGISTER_PAGE =
8123
8162
 
8124
8163
  function renderAccountRegister(opts) {
8125
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"));
8126
8172
  return _wrap({
8127
8173
  title: "Create account",
8128
8174
  shop_name: opts.shop_name || "blamejs.shop",
8129
8175
  cart_count: opts.cart_count,
8130
8176
  theme_css: opts.theme_css,
8131
- body: ACCOUNT_REGISTER_PAGE.replace("RAW_REGISTER_SCRIPT", _islandScript("passkey-register.js")),
8177
+ body: body,
8132
8178
  });
8133
8179
  }
8134
8180
 
@@ -8880,6 +8926,89 @@ function mount(router, deps) {
8880
8926
  // launch flow converts the reservation into a Stripe-gated order).
8881
8927
  var preorder = deps.preorder || null;
8882
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
+
8883
9012
  // Active cookie-consent policy version. `_liveConsentPolicy()` reads it
8884
9013
  // from the consent primitive per request so a runtime `policyVersion`
8885
9014
  // bump takes effect on the gate immediately, and refreshes the module
@@ -11049,9 +11178,27 @@ function mount(router, deps) {
11049
11178
  loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
11050
11179
  prefill: prefill,
11051
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,
11052
11186
  };
11053
11187
  }
11054
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
+
11055
11202
  router.get("/checkout", async function (req, res) {
11056
11203
  var sid = _readSidCookie(req);
11057
11204
  if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
@@ -11069,6 +11216,7 @@ function mount(router, deps) {
11069
11216
  // cart page) so the summary's per-line totals + subtotal match what
11070
11217
  // the shopper saw on /cart.
11071
11218
  var lines = await _repriceCartLines(rawLines);
11219
+ _setCheckoutCsp(res);
11072
11220
  _send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
11073
11221
  });
11074
11222
 
@@ -11097,6 +11245,27 @@ function mount(router, deps) {
11097
11245
  res.status(303); res.setHeader && res.setHeader("location", "/cart");
11098
11246
  return res.end ? res.end() : res.send("");
11099
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
+ }
11100
11269
  var shipTo = _shipToFromBody(body);
11101
11270
  try {
11102
11271
  // default_shipping_id may be a literal string or an
@@ -11155,6 +11324,7 @@ function mount(router, deps) {
11155
11324
  // the summary against it (exact, not estimated) so the inline
11156
11325
  // re-render shows the same total the confirm path computed.
11157
11326
  var confirmedTo = (shipTo && /^[A-Z]{2}$/.test(shipTo.country)) ? shipTo : null;
11327
+ _setCheckoutCsp(res);
11158
11328
  return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg, confirmedTo)));
11159
11329
  }
11160
11330
  } catch (_re) { /* fall through to the styled error page */ }
@@ -11195,6 +11365,11 @@ function mount(router, deps) {
11195
11365
  if (!sid) return _json(400, { error: "no-session" });
11196
11366
  var c = await deps.cart.bySession(sid);
11197
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" });
11198
11373
  var shipTo = _shipToFromBody(body);
11199
11374
  try {
11200
11375
  var defaultShipId = typeof deps.default_shipping_id === "function"
@@ -11272,6 +11447,12 @@ function mount(router, deps) {
11272
11447
  res.status(503);
11273
11448
  return res.end ? res.end("Stripe publishable key not configured") : res.send("Stripe publishable key not configured");
11274
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"]));
11275
11456
  _send(res, 200, renderPayPage({
11276
11457
  order: o,
11277
11458
  client_secret: clientSecret,
@@ -11569,23 +11750,44 @@ function mount(router, deps) {
11569
11750
  router.get("/account/login", async function (req, res) {
11570
11751
  var cartCount = await _cartCountForReq(req);
11571
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");
11572
11756
  _send(res, 200, renderAccountLogin({
11573
11757
  shop_name: shopName,
11574
11758
  cart_count: cartCount,
11575
11759
  google_enabled: !!deps.oauthGoogle,
11576
11760
  apple_enabled: !!deps.oauthApple,
11577
11761
  error: url && url.searchParams.get("error"),
11762
+ captcha_kind: captchaLoginOn ? captchaKind : null,
11763
+ captcha_public_key: captchaLoginOn ? captchaPubKey : null,
11578
11764
  }));
11579
11765
  });
11580
11766
 
11581
11767
  router.get("/account/register", async function (req, res) {
11582
11768
  var cartCount = await _cartCountForReq(req);
11583
- _send(res, 200, renderAccountRegister({ shop_name: shopName, cart_count: cartCount }));
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
+ }));
11584
11778
  });
11585
11779
 
11586
11780
  router.post("/account/passkey/register-begin", async function (req, res) {
11587
11781
  try {
11588
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
+ }
11589
11791
  // Persist the customer row up-front. The address is the
11590
11792
  // registration's natural identifier — if enrollment fails
11591
11793
  // the customer can re-attempt with the same email; the
@@ -11701,6 +11903,15 @@ function mount(router, deps) {
11701
11903
  router.post("/account/passkey/login-begin", async function (req, res) {
11702
11904
  try {
11703
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
+ }
11704
11915
  var hash = deps.customers.hashEmail(body.email);
11705
11916
  var customer = await deps.customers.byEmailHash(hash);
11706
11917
  // Even when the customer doesn't exist, return a valid-shaped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {