@blamejs/blamejs-shop 0.2.22 → 0.2.23

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.2.x
10
10
 
11
+ - v0.2.23 (2026-05-29) — **Rate limiting and cross-site request isolation on the request lifecycle.** Two request-lifecycle defenses are now composed into every storefront and admin request. A per-client-IP rate limit backs the whole site (a generous global budget that a normal browsing and checkout session never trips) with tighter budgets on the abusable POST/auth endpoints — sign-in, passkey enrolment, checkout, gift-card balance lookup, account registration, newsletter, review/question submit, and survey response — so credential and passkey spraying, gift-card brute-forcing, and checkout flooding are shut down. Separately, a fetch-metadata gate refuses cross-site state-changing requests using the browser's Sec-Fetch headers, adding CSRF defense-in-depth on top of the SameSite session cookies. The client IP is read from the edge-forwarded header (the container sits behind the Worker), so limits apply per real visitor; the payment-webhook and health-probe paths are exempt. No change for normal use. **Added:** *Per-client rate limiting* — A generous global token-bucket limit per client IP backs the whole site, with tight fixed-window budgets on the abusable POST/auth endpoints (login, passkey register/add, checkout, gift-card balance, register, newsletter, review/question, survey). Keyed on the real client IP forwarded from the edge; payment-webhook and health-probe paths are exempt. The single-instance container uses an in-memory backend. · *Cross-site request isolation (fetch-metadata)* — Cross-site state-changing requests (the CSRF vector) are refused using the browser's Sec-Fetch-Site/Mode/Dest headers — same-origin requests and direct navigations pass; payment webhooks are exempt. This restores defense-in-depth alongside the SameSite session cookies.
12
+
11
13
  - v0.2.22 (2026-05-29) — **See your full order history, track shipments, reorder, and request returns.** The customer order surface is now complete end-to-end. A new order-history list shows every order (cursor-paginated), and each order page gains a status timeline, a carrier tracking panel (tracking number + the carrier's public tracking link), a one-click Reorder that rebuilds a cart from the order, and a Request-a-return button for eligible orders — the same Reorder/Return actions also appear per row on the account dashboard and the history list. On the operator side, the admin order screen gets a shipment panel to attach a carrier + tracking number and record shipment events, and the returns console's Refund now issues the payment-provider refund first (then records the RMA) whenever the order has a captured charge, so a return can't be marked refunded while the customer is left un-refunded; manual/guest orders with no charge fall back to an explicit record-only step. **Added:** *Order history + tracking + reorder + returns (customer)* — `/account/orders` lists all your orders with pagination; each order page shows a status timeline and carrier tracking, plus Reorder (`POST /orders/:id/reorder` rebuilds a cart from the order's lines at current prices) and Request-a-return for eligible orders. The dashboard and history rows carry the same actions. · *Shipment + tracking panel (admin)* — The admin order detail screen can attach a shipment (carrier + tracking number) and record shipment events; marking delivered advances the order's lifecycle. Tracking the order surfaces the carrier's public tracking link to the customer. **Changed:** *Return refunds move the money* — The returns console Refund action now issues the payment-provider refund first and records the RMA only on success when the order has a captured charge, so an approved return is never marked refunded without the customer actually being refunded. Orders with no captured charge use an explicit record-only step that points to the order.
12
14
 
13
15
  - v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.22",
2
+ "version": "0.2.23",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/index.js CHANGED
@@ -40,6 +40,7 @@ try {
40
40
  module.exports.framework = framework;
41
41
 
42
42
  Object.assign(module.exports, {
43
+ securityMiddleware: require("./security-middleware"),
43
44
  externaldbD1: require("./externaldb-d1"),
44
45
  r2Bridge: require("./r2-bridge"),
45
46
  catalog: require("./catalog"),
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ /**
3
+ * Request-lifecycle security wiring — composes the vendored blamejs
4
+ * middleware that defends the storefront + admin request paths into a
5
+ * single place both the production entry point (server.js) and the
6
+ * end-to-end harness (test/e2e/serve.js) wire identically.
7
+ *
8
+ * Three layers, all per-client-IP:
9
+ *
10
+ * 1. A generous GLOBAL rate limit (token bucket) — the backstop
11
+ * against credential / passkey spraying, gift-card balance
12
+ * brute-force, checkout hammering, and unauthenticated row-flood
13
+ * writes. Sized so a normal browsing + checkout session (page
14
+ * navigations + form POSTs + the cart-count island fetch) never
15
+ * trips it; only a flood does.
16
+ *
17
+ * 2. TIGHT per-route budgets on the abusable POST / auth endpoints
18
+ * (login, passkey register, checkout, gift-card balance lookup,
19
+ * account register, newsletter, review / question submit, survey
20
+ * response). A human does well under five of these a minute, so a
21
+ * ~10/min budget is invisible to real use and shuts spray down.
22
+ *
23
+ * 3. fetch-metadata (Sec-Fetch-Site / -Mode / -Dest) — refuses
24
+ * cross-site state-changing requests WITHOUT needing a per-form
25
+ * token, restoring CSRF defense-in-depth on top of the storefront's
26
+ * SameSite session cookies. The payment webhook routes are exempt
27
+ * (a payment processor's server-to-server POST is cross-site by
28
+ * nature and carries its own HMAC signature check).
29
+ *
30
+ * THE CLIENT-IP CONSTRAINT. In production the Node container sits BEHIND
31
+ * the Cloudflare Worker: every container request arrives via the
32
+ * Worker's forward, so the socket peer is the CF fabric, not the user.
33
+ * Cloudflare injects the real client address as `cf-connecting-ip`
34
+ * (with `x-real-ip` as a mirror); the Worker forwards both verbatim.
35
+ * Every limiter here therefore keys on that header, falling back to the
36
+ * socket address only for direct (non-proxied) connections such as the
37
+ * e2e harness and local dev. Keying on the socket would put every
38
+ * visitor in ONE bucket behind the fabric and let a single global limit
39
+ * throttle the whole store.
40
+ *
41
+ * The container runs as a single instance (max_instances=1), so the
42
+ * default in-memory rate-limit backend is correct — there is no second
43
+ * replica to coordinate a shared counter with, and the in-memory token
44
+ * bucket / fixed window need no SQL hop.
45
+ */
46
+
47
+ var b = require("./vendor/blamejs");
48
+
49
+ var C = b.constants;
50
+
51
+ // Payment webhooks are server-to-server POSTs from Stripe / PayPal:
52
+ // cross-site by nature, unthrottleable by a per-IP human budget, and
53
+ // already authenticated by an HMAC signature the edge + container both
54
+ // verify. They are exempt from BOTH the rate limiters and fetch-metadata.
55
+ var WEBHOOK_PATHS = ["/api/webhooks/stripe", "/api/webhooks/paypal"];
56
+
57
+ // Liveness / readiness probe — the container's Docker HEALTHCHECK hits
58
+ // this on a fixed cadence; never rate-limit it or a slow cold start
59
+ // could wedge the health signal.
60
+ var HEALTH_PATH = "/_/health";
61
+
62
+ // The abusable endpoints — POST / auth surfaces where a human does
63
+ // well under five requests a minute. Each gets its own per-client-IP
64
+ // budget so a spray against one can't borrow another's headroom, and a
65
+ // legitimate burst on one (a shopper re-submitting a slow checkout)
66
+ // never eats the login budget. Entries are matched as path PREFIXES so
67
+ // the dynamic `:slug` / `:token` segments and the begin/finish passkey
68
+ // sub-paths are all covered by a single stem.
69
+ //
70
+ // Prefix coverage notes:
71
+ // /account/login -> /account/login, /account/login/google, ...
72
+ // /account/register -> /account/register AND /account/register-begin
73
+ // /account/passkey/ -> register-begin / register-finish / add-* begin+finish
74
+ // /products/ -> the review + question submit sub-paths (gated to POST below)
75
+ // /survey/ -> /survey/:token (GET form + POST submit)
76
+ var TIGHT_PREFIXES = [
77
+ "/account/login",
78
+ "/account/register",
79
+ "/account/passkey/",
80
+ "/checkout",
81
+ "/gift-cards/balance",
82
+ "/gift-cards",
83
+ "/newsletter",
84
+ "/products/",
85
+ "/survey/",
86
+ ];
87
+
88
+ /**
89
+ * Resolve the real client IP for rate-limit keying. Reads the
90
+ * Cloudflare-injected `cf-connecting-ip` first (the canonical single
91
+ * client address behind the fabric), then `x-real-ip` (its mirror),
92
+ * then falls back to the socket address via `b.requestHelpers.clientIp`
93
+ * for direct connections (e2e harness, local dev). Always returns a
94
+ * non-empty string so two un-identifiable clients never collapse into
95
+ * the same bucket as a real IP — request-shape reader, returns a
96
+ * default, never throws.
97
+ */
98
+ function clientKey(req) {
99
+ var headers = (req && req.headers) || {};
100
+ var cf = headers["cf-connecting-ip"];
101
+ if (typeof cf === "string" && cf.length > 0) return cf.trim();
102
+ var real = headers["x-real-ip"];
103
+ if (typeof real === "string" && real.length > 0) return real.trim();
104
+ var sock = b.requestHelpers.clientIp(req);
105
+ return sock || "unknown";
106
+ }
107
+
108
+ /**
109
+ * Build the GLOBAL rate-limit options for createApp's
110
+ * `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
111
+ * session is smoothed rather than clipped at a window edge.
112
+ *
113
+ * burst — the standing buffer a client may spend at once.
114
+ * refillPerSecond — the sustained per-second throughput.
115
+ *
116
+ * 300 burst + 5/s refill = a 300-request standing buffer that refills
117
+ * to full over a minute, i.e. a 300/min sustained ceiling per client
118
+ * IP. A very active human session (rapid navigation, the cart-count
119
+ * island firing once per page view, form POSTs) lands far under this;
120
+ * an unauthenticated spray flood blows straight through it. The webhook
121
+ * + health paths skip the limiter entirely.
122
+ */
123
+ function globalRateLimitOpts() {
124
+ return {
125
+ backend: "memory",
126
+ algorithm: "token-bucket",
127
+ burst: 300,
128
+ refillPerSecond: 5,
129
+ keyFn: clientKey,
130
+ skipPaths: WEBHOOK_PATHS.concat([HEALTH_PATH]),
131
+ };
132
+ }
133
+
134
+ function _hasPrefix(pathname, prefixes) {
135
+ for (var i = 0; i < prefixes.length; i += 1) {
136
+ if (pathname.indexOf(prefixes[i]) === 0) return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Mount the per-route tight rate limiters + the fetch-metadata gate on
143
+ * the router inside the operator's `routes(r)` chain. Call AFTER the
144
+ * webhook raw-body capture + bodyParser are mounted (so the gate reads
145
+ * a fully-shaped request) and BEFORE the storefront / admin routes.
146
+ *
147
+ * @param r the blamejs Router passed to createApp's routes(r) callback.
148
+ */
149
+ function mountRouteGuards(r) {
150
+ // --- fetch-metadata: cross-site state-change isolation -------------
151
+ //
152
+ // Refuses cross-site POST / PUT / DELETE / PATCH (the CSRF vector)
153
+ // using the browser-supplied Sec-Fetch-* headers — same-origin and
154
+ // same-site requests, plus direct navigations (typed URL / bookmark),
155
+ // pass through. Legacy / non-browser clients that omit Sec-Fetch-*
156
+ // are deferred to (allowMissing default) so server-to-server callers
157
+ // and old browsers aren't broken; the storefront's SameSite session
158
+ // cookie is the gate for those. The webhook paths are exempt because
159
+ // a payment processor's callback is legitimately cross-site.
160
+ var fmGate = b.middleware.fetchMetadata({
161
+ allowSameSite: true,
162
+ allowCrossSite: false,
163
+ allowMissing: true,
164
+ allowedNavigate: true,
165
+ });
166
+ r.use(function fetchMetadataGuard(req, res, next) {
167
+ var pathname = req.pathname || req.url || "/";
168
+ if (_hasPrefix(pathname, WEBHOOK_PATHS)) return next();
169
+ return fmGate(req, res, next);
170
+ });
171
+
172
+ // --- tight per-route rate limiters ---------------------------------
173
+ //
174
+ // One fixed-window limiter keyed on (client IP + path) so each
175
+ // abusable endpoint carries its own per-client budget. Fixed-window
176
+ // (rather than token-bucket) gives a flat, predictable "N per minute"
177
+ // ceiling that's easy to reason about for an auth surface. ~10/min is
178
+ // an order of magnitude above human use of any of these endpoints and
179
+ // shuts a spray down hard.
180
+ var tightLimiter = b.middleware.rateLimit({
181
+ backend: "memory",
182
+ algorithm: "fixed-window",
183
+ max: 10,
184
+ windowMs: C.TIME.minutes(1),
185
+ keyFn: function (req) {
186
+ return clientKey(req) + "|" + (req.pathname || req.url || "/");
187
+ },
188
+ });
189
+ r.use(function tightRateGuard(req, res, next) {
190
+ var pathname = req.pathname || req.url || "/";
191
+ // Never throttle the webhook or health paths.
192
+ if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
193
+ if (!_hasPrefix(pathname, TIGHT_PREFIXES)) return next();
194
+ // `/products/` and `/gift-cards` carry GET reads (PDP, balance page
195
+ // render) that a shopper hits freely — only gate the state-changing
196
+ // / lookup POSTs there. Login / register / passkey / checkout /
197
+ // newsletter / survey are gated on every method (their GETs are the
198
+ // form render, which a spray would hammer just the same to harvest a
199
+ // fresh token, so the budget covers both).
200
+ var method = (req.method || "GET").toUpperCase();
201
+ var gateAllMethods = pathname.indexOf("/products/") !== 0 &&
202
+ pathname.indexOf("/gift-cards") !== 0;
203
+ if (!gateAllMethods && method !== "POST") return next();
204
+ return tightLimiter(req, res, next);
205
+ });
206
+
207
+ return { fetchMetadata: fmGate, tightLimiter: tightLimiter };
208
+ }
209
+
210
+ module.exports = {
211
+ clientKey: clientKey,
212
+ globalRateLimitOpts: globalRateLimitOpts,
213
+ mountRouteGuards: mountRouteGuards,
214
+ WEBHOOK_PATHS: WEBHOOK_PATHS,
215
+ HEALTH_PATH: HEALTH_PATH,
216
+ TIGHT_PREFIXES: TIGHT_PREFIXES,
217
+ };
package/lib/storefront.js CHANGED
@@ -10161,9 +10161,11 @@ function mount(router, deps) {
10161
10161
 
10162
10162
  // POST /cart/lines — add a line. Reads variant_id + qty from the
10163
10163
  // form body (b.middleware.bodyParser parses it into req.body).
10164
- // CSRF token validation is the responsibility of the csrfProtect
10165
- // middleware mounted at the app level (server.js). Redirects to
10166
- // /cart on success so a refresh doesn't re-submit the form.
10164
+ // Cross-site forgery of this state-changing POST is refused by the
10165
+ // app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
10166
+ // session cookie; both are wired in server.js via
10167
+ // lib/security-middleware. Redirects to /cart on success so a
10168
+ // refresh doesn't re-submit the form.
10167
10169
  router.post("/cart/lines", async function (req, res) {
10168
10170
  var body = req.body || {};
10169
10171
  var variantId = body.variant_id;
@@ -10421,8 +10423,10 @@ function mount(router, deps) {
10421
10423
  //
10422
10424
  // The decision is written to a sealed first-party cookie (the gate) AND
10423
10425
  // recorded in the cookie-consent ledger (the audit trail) when the
10424
- // primitive is wired. CSRF / origin / fetch-metadata defenses are the
10425
- // framework middleware already on every POST — no per-route re-check.
10426
+ // primitive is wired. Cross-site forgery of this POST is refused by the
10427
+ // app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
10428
+ // session cookie (both wired in server.js via lib/security-middleware)
10429
+ // — no per-route re-check.
10426
10430
 
10427
10431
  // Same-origin path guard for `return_to`. Accepts a single leading slash
10428
10432
  // followed by a non-slash (so `//evil.example` and absolute URLs are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
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": {