@blamejs/blamejs-shop 0.2.21 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.2.20 (2026-05-29) — **Manage a subscription yourself — pause, skip, change, or reactivate.** The subscriptions account screen previously only let a customer cancel. It now exposes the full self-service set: pause and resume, skip the next shipment, change the quantity, change the delivery frequency, and reactivate a recently-cancelled subscription within its grace window. Each action is state-gated (only the controls that apply to a subscription's current state are shown), confirm-aware where it changes billing, and reports back with a status notice. No more cancelling just to make a change. **Added:** *Subscription self-management* — `/account/subscriptions` gains Pause / Resume, Skip next shipment, Change quantity, Change frequency, and Reactivate (within the grace window) alongside Cancel. Controls are shown only when valid for the subscription's state; quantity and frequency are validated server-side; pause and cancel go through a confirmation step.
package/README.md CHANGED
@@ -87,7 +87,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
87
87
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
88
88
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
89
89
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
90
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
90
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
91
91
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
92
92
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
93
93
 
package/lib/admin.js CHANGED
@@ -315,6 +315,7 @@ function mount(router, deps) {
315
315
  var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
316
316
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
317
317
  var customers = deps.customers || null; // read-only customers console disabled when absent
318
+ var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
318
319
 
319
320
  // Which optional console sections are wired — gates their nav links so a
320
321
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -1175,6 +1176,21 @@ function mount(router, deps) {
1175
1176
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
1176
1177
  }));
1177
1178
  var url = req.url ? new URL(req.url, "http://localhost") : null;
1179
+ // Shipment + carrier-event ledger for the tracking panel. Best-effort:
1180
+ // the shipments table may be unmigrated on a given deploy, so a read
1181
+ // failure degrades to "no shipments yet" rather than 500-ing the
1182
+ // detail. Each shipment is hydrated via getShipment so the panel can
1183
+ // list its carrier events.
1184
+ var shipments = [];
1185
+ if (orderTracking) {
1186
+ try {
1187
+ var shipRows = await orderTracking.listForOrder(o.id);
1188
+ for (var si = 0; si < shipRows.length; si += 1) {
1189
+ var full = await orderTracking.getShipment(shipRows[si].id);
1190
+ shipments.push(full || shipRows[si]);
1191
+ }
1192
+ } catch (_e) { shipments = []; }
1193
+ }
1178
1194
  _sendHtml(res, 200, renderAdminOrder({
1179
1195
  shop_name: deps.shop_name,
1180
1196
  nav_available: navAvailable,
@@ -1183,12 +1199,106 @@ function mount(router, deps) {
1183
1199
  // Refund moves money, so the console only offers it when a payment
1184
1200
  // provider is wired AND the order has a captured intent to refund.
1185
1201
  can_refund: !!(payment && o.payment_intent_id),
1202
+ // Shipment/tracking panel only renders when the tracking primitive
1203
+ // is wired; the carrier + status enums drive its form selects.
1204
+ can_track: !!orderTracking,
1205
+ shipments: shipments,
1206
+ carriers: orderTracking ? orderTracking.CARRIERS : null,
1207
+ statuses: orderTracking ? orderTracking.STATUSES : null,
1186
1208
  moved: url && url.searchParams.get("moved"),
1209
+ ship_done: url && url.searchParams.get("ship"),
1187
1210
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
1188
1211
  }));
1189
1212
  },
1190
1213
  ));
1191
1214
 
1215
+ // ---- shipment tracking (operator-managed) ---------------------------
1216
+ //
1217
+ // Attach a shipment to an order (carrier + optional tracking number) and
1218
+ // record carrier events against it. Composes order-tracking; mounted
1219
+ // only when that primitive is wired. Both the JSON API and the browser
1220
+ // console funnel through `W()` so writes carry the CSRF + audit
1221
+ // discipline of the rest of the console. The browser side redirects back
1222
+ // to the order detail (PRG); a bad shape (TypeError) surfaces as a
1223
+ // notice, never a 500.
1224
+ if (orderTracking) {
1225
+ router.post("/admin/orders/:id/shipments", _pageOrApi(false,
1226
+ W("order.shipment.create", async function (req, res) {
1227
+ var body = req.body || {};
1228
+ var ship;
1229
+ try {
1230
+ ship = await orderTracking.createShipment({
1231
+ order_id: req.params.id,
1232
+ carrier: body.carrier,
1233
+ carrier_other_name: body.carrier_other_name || undefined,
1234
+ tracking_number: body.tracking_number || undefined,
1235
+ });
1236
+ } catch (e) {
1237
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
1238
+ throw e;
1239
+ }
1240
+ _json(res, 201, ship);
1241
+ return { id: req.params.id };
1242
+ }),
1243
+ async function (req, res) {
1244
+ var id = req.params.id;
1245
+ var enc = encodeURIComponent(id);
1246
+ var body = req.body || {};
1247
+ try {
1248
+ await orderTracking.createShipment({
1249
+ order_id: id,
1250
+ carrier: body.carrier,
1251
+ carrier_other_name: (body.carrier_other_name && body.carrier_other_name.length) ? body.carrier_other_name : undefined,
1252
+ tracking_number: (body.tracking_number && body.tracking_number.length) ? body.tracking_number : undefined,
1253
+ });
1254
+ } catch (e) {
1255
+ if (!(e instanceof TypeError)) throw e;
1256
+ return _redirect(res, "/admin/orders/" + enc + "?err=1");
1257
+ }
1258
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.shipment.create", outcome: "success", metadata: { id: id } });
1259
+ _redirect(res, "/admin/orders/" + enc + "?ship=1");
1260
+ },
1261
+ ));
1262
+
1263
+ router.post("/admin/orders/:id/shipments/:shipmentId/events", _pageOrApi(false,
1264
+ W("order.shipment.event", async function (req, res) {
1265
+ var body = req.body || {};
1266
+ var ev;
1267
+ try {
1268
+ ev = await orderTracking.recordEvent({
1269
+ shipment_id: req.params.shipmentId,
1270
+ status: body.status,
1271
+ location: body.location || undefined,
1272
+ detail: body.detail || undefined,
1273
+ });
1274
+ } catch (e) {
1275
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
1276
+ throw e;
1277
+ }
1278
+ _json(res, 200, ev);
1279
+ return { id: req.params.id };
1280
+ }),
1281
+ async function (req, res) {
1282
+ var id = req.params.id;
1283
+ var enc = encodeURIComponent(id);
1284
+ var body = req.body || {};
1285
+ try {
1286
+ await orderTracking.recordEvent({
1287
+ shipment_id: req.params.shipmentId,
1288
+ status: body.status,
1289
+ location: (body.location && body.location.length) ? body.location : undefined,
1290
+ detail: (body.detail && body.detail.length) ? body.detail : undefined,
1291
+ });
1292
+ } catch (e) {
1293
+ if (!(e instanceof TypeError)) throw e;
1294
+ return _redirect(res, "/admin/orders/" + enc + "?err=1");
1295
+ }
1296
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.shipment.event", outcome: "success", metadata: { id: id, shipment_id: req.params.shipmentId } });
1297
+ _redirect(res, "/admin/orders/" + enc + "?ship=1");
1298
+ },
1299
+ ));
1300
+ }
1301
+
1192
1302
  router.post("/admin/orders/:id/transition", _pageOrApi(false,
1193
1303
  W("order.transition", async function (req, res) {
1194
1304
  var body = req.body || {};
@@ -1720,17 +1830,37 @@ function mount(router, deps) {
1720
1830
  shop_name: deps.shop_name, nav_available: navAvailable, returns: [], status: "pending", notice: "Return not found.",
1721
1831
  }));
1722
1832
  var url = req.url ? new URL(req.url, "http://localhost") : null;
1833
+ var rmaCtx = await _rmaProviderContext(rma);
1723
1834
  _sendHtml(res, 200, renderAdminReturn({
1724
1835
  shop_name: deps.shop_name,
1725
1836
  nav_available: navAvailable,
1726
1837
  rma: rma,
1727
1838
  transitions: returns.transitionsFrom(rma.status),
1839
+ // When a payment provider is wired AND the linked order has a
1840
+ // captured intent, the Refund action moves money through the
1841
+ // provider via a confirm interstitial. Absent either, it stays
1842
+ // record-only with a pointer to the order page for the money side.
1843
+ can_provider_refund: rmaCtx.canProviderRefund,
1728
1844
  moved: url && url.searchParams.get("moved"),
1729
1845
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this return." : null,
1730
1846
  }));
1731
1847
  },
1732
1848
  ));
1733
1849
 
1850
+ // Resolve whether an RMA can be refunded through the payment provider:
1851
+ // the provider must be wired AND the linked order must carry a captured
1852
+ // payment intent. Returns the linked order too so the caller can issue
1853
+ // the refund without a second fetch. Best-effort — a bad/legacy order id
1854
+ // degrades to "record-only" rather than throwing.
1855
+ async function _rmaProviderContext(rma) {
1856
+ var ctx = { order: null, canProviderRefund: false };
1857
+ if (!payment || !rma || !rma.order_id) return ctx;
1858
+ try { ctx.order = await order.get(rma.order_id); }
1859
+ catch (_e) { ctx.order = null; }
1860
+ ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id);
1861
+ return ctx;
1862
+ }
1863
+
1734
1864
  // The browser side of an RMA action: run `opFn(id, body)`, then PRG
1735
1865
  // back to the detail. A bad id / shape (TypeError) or an FSM refusal /
1736
1866
  // not-found (mapped by _returnsClientError) becomes a notice on the
@@ -1803,22 +1933,124 @@ function mount(router, deps) {
1803
1933
  function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
1804
1934
  ));
1805
1935
 
1806
- router.post("/admin/returns/:id/refund", _returnAction(
1936
+ // Issue the provider refund for an RMA (when a captured intent exists),
1937
+ // then record the RMA refund. The provider call runs FIRST — only if it
1938
+ // succeeds does the RMA move to refunded — so the queue never marks a
1939
+ // return refunded with the customer never paid back. The refund amount
1940
+ // is the RMA's approved refund_amount_minor (set at approve time);
1941
+ // absent one, the provider issues a full refund of the intent.
1942
+ async function _rmaProviderRefund(rma, order2, body) {
1943
+ var idem = "rma-refund:" + rma.id + ":" + (body.idempotency_suffix || b.uuid.v7());
1944
+ var refund = await payment.refund({
1945
+ payment_intent: order2.payment_intent_id,
1946
+ amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
1947
+ reason: "requested_by_customer",
1948
+ metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
1949
+ }, idem);
1950
+ var updated = await returns.refund(rma.id, {
1951
+ operator_notes: (body.operator_notes && body.operator_notes.length)
1952
+ ? body.operator_notes
1953
+ : ("provider refund " + refund.id),
1954
+ });
1955
+ return { refund: refund, rma: updated };
1956
+ }
1957
+
1958
+ // Browser confirmation interstitial for a provider-backed RMA refund —
1959
+ // it moves money + advances the RMA, so the console makes the operator
1960
+ // confirm (the CSP forbids a client confirm() dialog). Only meaningful
1961
+ // when a captured intent exists; otherwise it bounces to the detail.
1962
+ router.post("/admin/returns/:id/refund/confirm", _pageOrApi(false,
1963
+ R(async function (_req, res) {
1964
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/returns/:id/refund directly for the JSON API");
1965
+ }),
1966
+ async function (req, res) {
1967
+ var id = req.params.id;
1968
+ var enc = encodeURIComponent(id);
1969
+ var rma;
1970
+ try { rma = await returns.get(id); }
1971
+ catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
1972
+ if (!rma) return _redirect(res, "/admin/returns/" + enc + "?err=1");
1973
+ var rmaCtx = await _rmaProviderContext(rma);
1974
+ if (!rmaCtx.canProviderRefund) {
1975
+ // No captured intent — there's nothing to confirm; the detail's
1976
+ // record-only form handles it.
1977
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
1978
+ }
1979
+ var amount = (rma.refund_amount_minor != null && rma.refund_amount_minor > 0)
1980
+ ? pricing.format(rma.refund_amount_minor, rma.refund_currency || "USD")
1981
+ : pricing.format(rmaCtx.order.grand_total_minor, rmaCtx.order.currency);
1982
+ _sendHtml(res, 200, renderAdminConfirm({
1983
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "returns",
1984
+ heading: "Refund return " + _htmlEscape(rma.rma_code || rma.id.slice(0, 8)) + "?",
1985
+ consequence: "This issues a refund of " + amount + " through the payment provider and cannot be undone.",
1986
+ detail: "The provider refund runs first; only if it succeeds does the return move to a refunded state.",
1987
+ action: "/admin/returns/" + _htmlEscape(rma.id) + "/refund",
1988
+ confirm_label: "Refund " + amount,
1989
+ cancel_href: "/admin/returns/" + enc,
1990
+ }));
1991
+ },
1992
+ ));
1993
+
1994
+ router.post("/admin/returns/:id/refund", _pageOrApi(false,
1807
1995
  W("return.refund", async function (req, res) {
1808
1996
  var body = req.body || {};
1809
1997
  var rma;
1998
+ try { rma = await returns.get(req.params.id); }
1999
+ catch (e) {
2000
+ if (e instanceof TypeError) return _problem(res, 404, "return-not-found", e.message);
2001
+ throw e;
2002
+ }
2003
+ if (!rma) return _problem(res, 404, "return-not-found");
2004
+ var rmaCtx = await _rmaProviderContext(rma);
2005
+ // Provider-backed path: move money first, then record the RMA.
2006
+ if (rmaCtx.canProviderRefund) {
2007
+ var result;
2008
+ try { result = await _rmaProviderRefund(rma, rmaCtx.order, body); }
2009
+ catch (e) {
2010
+ var ce = _returnsClientError(e);
2011
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
2012
+ return _problem(res, 502, "provider-refund-failed", (e && e.message) || String(e));
2013
+ }
2014
+ _json(res, 200, result.rma);
2015
+ return result.rma;
2016
+ }
2017
+ // Record-only path (no captured intent / no provider wired).
2018
+ var recorded;
1810
2019
  try {
1811
- rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
2020
+ recorded = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
1812
2021
  } catch (e) {
1813
- var ce = _returnsClientError(e);
1814
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
2022
+ var ce2 = _returnsClientError(e);
2023
+ if (ce2) return _problem(res, ce2.status, ce2.slug, e.message);
1815
2024
  throw e;
1816
2025
  }
1817
- _json(res, 200, rma);
1818
- return rma;
2026
+ _json(res, 200, recorded);
2027
+ return recorded;
1819
2028
  }),
1820
- "return.refund",
1821
- function (id, body) { return returns.refund(id, { operator_notes: body.operator_notes || undefined }); },
2029
+ async function (req, res) {
2030
+ var id = req.params.id;
2031
+ var enc = encodeURIComponent(id);
2032
+ var rma;
2033
+ try { rma = await returns.get(id); }
2034
+ catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
2035
+ if (!rma) return _redirect(res, "/admin/returns/" + enc + "?err=1");
2036
+ var rmaCtx = await _rmaProviderContext(rma);
2037
+ try {
2038
+ if (rmaCtx.canProviderRefund) {
2039
+ await _rmaProviderRefund(rma, rmaCtx.order, req.body || {});
2040
+ } else {
2041
+ await returns.refund(id, { operator_notes: (req.body || {}).operator_notes || undefined });
2042
+ }
2043
+ } catch (e) {
2044
+ if (e instanceof TypeError || _returnsClientError(e)) {
2045
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
2046
+ }
2047
+ // A provider-refund failure leaves the RMA untouched (the record
2048
+ // step only runs after a successful refund) — surface as a notice.
2049
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
2050
+ }
2051
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".return.refund", outcome: "success", metadata: { id: id, provider: rmaCtx.canProviderRefund } });
2052
+ _redirect(res, "/admin/returns/" + enc + "?moved=1");
2053
+ },
1822
2054
  ));
1823
2055
 
1824
2056
  router.post("/admin/returns/:id/reject", _returnAction(
@@ -4389,6 +4621,7 @@ function renderAdminOrder(opts) {
4389
4621
  var o = opts.order;
4390
4622
  var transitions = opts.transitions || [];
4391
4623
  var moved = opts.moved ? "<div class=\"banner banner--ok\">Order updated.</div>" : "";
4624
+ var shipOk = opts.ship_done ? "<div class=\"banner banner--ok\">Shipment updated.</div>" : "";
4392
4625
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
4393
4626
 
4394
4627
  var lineRows = (o.lines || []).map(function (l) {
@@ -4444,6 +4677,72 @@ function renderAdminOrder(opts) {
4444
4677
  }).filter(Boolean).join(" ");
4445
4678
  var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
4446
4679
 
4680
+ // Shipment + tracking panel. Renders only when the tracking primitive is
4681
+ // wired (`can_track`). For each existing shipment: the carrier, status,
4682
+ // tracking number (linked to the carrier's public URL when known), and
4683
+ // its carrier-event log, plus a single-select form to record the next
4684
+ // event. A bottom form attaches a NEW shipment (carrier + optional
4685
+ // tracking number). Carrier + status option lists come from the
4686
+ // primitive's frozen enums (deps.carriers / deps.statuses).
4687
+ var trackingPanel = "";
4688
+ if (opts.can_track) {
4689
+ var carriers = opts.carriers || [];
4690
+ var statuses = opts.statuses || [];
4691
+ var carrierOpts = carriers.map(function (c) {
4692
+ return "<option value=\"" + _htmlEscape(c) + "\">" + _htmlEscape(c) + "</option>";
4693
+ }).join("");
4694
+ var shipments = opts.shipments || [];
4695
+ var shipBlocks = shipments.map(function (s) {
4696
+ var carrierLabel = s.carrier === "other" ? (s.carrier_other_name || "other") : s.carrier;
4697
+ var trackingCell = s.tracking_number
4698
+ ? (s.tracking_url
4699
+ ? "<a class=\"order-id\" href=\"" + _htmlEscape(String(s.tracking_url)) + "\" rel=\"noopener nofollow\" target=\"_blank\">" + _htmlEscape(String(s.tracking_number)) + " ↗</a>"
4700
+ : "<code class=\"order-id\">" + _htmlEscape(String(s.tracking_number)) + "</code>")
4701
+ : "<span class=\"meta\">—</span>";
4702
+ var eventRows = (s.events || []).map(function (e) {
4703
+ return "<tr><td>" + _htmlEscape(String(e.status)) + "</td>" +
4704
+ "<td>" + (e.location ? _htmlEscape(String(e.location)) : "<span class=\"meta\">—</span>") + "</td>" +
4705
+ "<td>" + _htmlEscape(_fmtDate(e.occurred_at)) + "</td></tr>";
4706
+ }).join("");
4707
+ var eventsTable = (s.events && s.events.length)
4708
+ ? "<table><thead><tr><th scope=\"col\">Status</th><th scope=\"col\">Location</th><th scope=\"col\">When</th></tr></thead><tbody>" + eventRows + "</tbody></table>"
4709
+ : "<p class=\"empty\">No carrier events yet.</p>";
4710
+ var statusOpts = statuses.map(function (st) {
4711
+ return "<option value=\"" + _htmlEscape(st) + "\">" + _htmlEscape(st) + "</option>";
4712
+ }).join("");
4713
+ var eventForm =
4714
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/shipments/" + _htmlEscape(s.id) + "/events\" class=\"return-action\">" +
4715
+ "<h4>Record event</h4>" +
4716
+ "<label class=\"form-field\"><span>Status</span><select name=\"status\" required>" + statusOpts + "</select></label>" +
4717
+ _setupField("Location", "location", "", "text", "Carrier-reported, optional.", " maxlength=\"128\"") +
4718
+ _setupField("Detail", "detail", "", "text", "Optional note.", " maxlength=\"512\"") +
4719
+ "<button class=\"btn\" type=\"submit\">Record event</button>" +
4720
+ "</form>";
4721
+ return "<div class=\"panel mt\">" +
4722
+ "<div class=\"order-shipment-head\">" +
4723
+ "<strong>" + _htmlEscape(String(carrierLabel)) + "</strong> " +
4724
+ "<span class=\"status-pill " + _htmlEscape(s.status) + "\">" + _htmlEscape(s.status) + "</span>" +
4725
+ "</div>" +
4726
+ "<p class=\"meta\">Tracking: " + trackingCell + "</p>" +
4727
+ eventsTable +
4728
+ eventForm +
4729
+ "</div>";
4730
+ }).join("");
4731
+ var addShipmentForm =
4732
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/shipments\" class=\"return-action\">" +
4733
+ "<h4>Add a shipment</h4>" +
4734
+ "<label class=\"form-field\"><span>Carrier</span><select name=\"carrier\" required>" + carrierOpts + "</select></label>" +
4735
+ _setupField("Carrier name (if “other”)", "carrier_other_name", "", "text", "Required only when carrier is “other”.", " maxlength=\"64\"") +
4736
+ _setupField("Tracking number", "tracking_number", "", "text", "Optional — links to the carrier's tracking page.", " maxlength=\"64\"") +
4737
+ "<button class=\"btn\" type=\"submit\">Add shipment</button>" +
4738
+ "</form>";
4739
+ trackingPanel =
4740
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Shipments &amp; tracking</h3>" +
4741
+ (shipments.length ? shipBlocks : "<p class=\"empty\">No shipments yet.</p>") +
4742
+ addShipmentForm +
4743
+ "</div>";
4744
+ }
4745
+
4447
4746
  var body =
4448
4747
  "<section class=\"mw-48\">" +
4449
4748
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
@@ -4451,7 +4750,7 @@ function renderAdminOrder(opts) {
4451
4750
  "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
4452
4751
  "<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
4453
4752
  (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
4454
- moved + notice +
4753
+ moved + shipOk + notice +
4455
4754
  "<div class=\"two-col\">" +
4456
4755
  "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
4457
4756
  "<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
@@ -4462,6 +4761,7 @@ function renderAdminOrder(opts) {
4462
4761
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
4463
4762
  "<div class=\"order-actions\">" + actions + "</div>" +
4464
4763
  "</div>" +
4764
+ trackingPanel +
4465
4765
  "</section>";
4466
4766
  return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
4467
4767
  }
@@ -4612,12 +4912,29 @@ function renderAdminReturn(opts) {
4612
4912
  "</form>");
4613
4913
  }
4614
4914
  if (has("refund")) {
4615
- actionBlocks.push(
4616
- "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
4617
- "<h4>Refund</h4><p class=\"meta\">Record the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " for this return.</p>" +
4618
- _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
4619
- "<button class=\"btn\" type=\"submit\">Refund</button>" +
4620
- "</form>");
4915
+ if (opts.can_provider_refund) {
4916
+ // A captured payment intent is linked the Refund button moves money
4917
+ // through the provider. It POSTs to the confirm interstitial (states
4918
+ // the amount + warns it's irreversible), whose Confirm button issues
4919
+ // the provider refund THEN records the RMA refund.
4920
+ actionBlocks.push(
4921
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund/confirm\" class=\"return-action\">" +
4922
+ "<h4>Refund</h4><p class=\"meta\">Issues the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " through the payment provider, then marks this return refunded.</p>" +
4923
+ "<button class=\"btn btn--danger\" type=\"submit\">Refund through provider</button>" +
4924
+ "</form>");
4925
+ } else {
4926
+ // No captured intent / no provider wired — the action records the RMA
4927
+ // refund only. The money side is issued separately (manual provider
4928
+ // refund, or the order page when the order carries an intent), made
4929
+ // explicit so the operator never assumes this moved money.
4930
+ actionBlocks.push(
4931
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
4932
+ "<h4>Refund</h4><p class=\"meta\">Record-only: marks the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " as issued. No money moves from here — issue the refund manually or from the " +
4933
+ "<a href=\"/admin/orders/" + _htmlEscape(String(r.order_id)) + "\">linked order</a>.</p>" +
4934
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
4935
+ "<button class=\"btn\" type=\"submit\">Mark refunded (record only)</button>" +
4936
+ "</form>");
4937
+ }
4621
4938
  }
4622
4939
  if (has("reject")) {
4623
4940
  actionBlocks.push(
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.21",
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
@@ -2813,7 +2813,7 @@ function renderReturns(opts) {
2813
2813
  : "<div class=\"account-empty\">" +
2814
2814
  "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M66 64 L100 50 L134 64 L100 78 Z\"/><path d=\"M66 64 V92 L100 106 L134 92 V64 M100 78 V106\"/><path d=\"M100 36 A20 20 0 0 1 120 56\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M100 28 L100 40 L110 38\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M88 70 H112\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
2815
2815
  "<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
2816
- "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
2816
+ "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders &rarr;</a>" +
2817
2817
  "</div>";
2818
2818
  // Success confirmation after submitting a return request. The RMA code
2819
2819
  // round-trips on the ?ok=<code> redirect so the notice can echo it back
@@ -4179,6 +4179,7 @@ var ORDER_PAGE =
4179
4179
  " <h1 class=\"section-head__title\">Order <code class=\"inline-code\">{{order_id}}</code></h1>\n" +
4180
4180
  " <p class=\"section-head__lede\">Status: <span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> {{status}}</span></p>\n" +
4181
4181
  " </header>\n" +
4182
+ " RAW_REORDER_NOTICE" +
4182
4183
  " <div class=\"order-page__grid\">\n" +
4183
4184
  " <div class=\"order-page__items\">\n" +
4184
4185
  " <h2 class=\"pdp__variants-title\">Items</h2>\n" +
@@ -4188,8 +4189,11 @@ var ORDER_PAGE =
4188
4189
  " <tbody>{{line_rows}}</tbody>\n" +
4189
4190
  " </table>\n" +
4190
4191
  " </div>\n" +
4192
+ " RAW_ORDER_ACTIONS" +
4191
4193
  " </div>\n" +
4192
4194
  " <aside class=\"order-page__totals\">\n" +
4195
+ " RAW_ORDER_TIMELINE" +
4196
+ " RAW_ORDER_TRACKING" +
4193
4197
  " <h2 class=\"pdp__variants-title\">Totals</h2>\n" +
4194
4198
  " <dl class=\"totals-list\">\n" +
4195
4199
  " <div><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
@@ -4222,6 +4226,142 @@ function _shipToAddressBlock(s) {
4222
4226
  "</div>";
4223
4227
  }
4224
4228
 
4229
+ // The customer-facing order lifecycle, in display order. Mirrors the
4230
+ // happy-path edges of the order FSM (pending → paid → fulfilling →
4231
+ // shipped → delivered); the terminal off-ramps (refunded / cancelled)
4232
+ // are surfaced as a distinct final step rather than a position on the
4233
+ // rail. Kept as a small ordered list so the timeline highlights every
4234
+ // step up to and including the current status.
4235
+ var ORDER_TIMELINE_STEPS = [
4236
+ { status: "pending", label: "Order placed" },
4237
+ { status: "paid", label: "Payment confirmed" },
4238
+ { status: "fulfilling", label: "Preparing your order" },
4239
+ { status: "shipped", label: "Shipped" },
4240
+ { status: "delivered", label: "Delivered" },
4241
+ ];
4242
+
4243
+ // A paid (or further-along) order is the customer's window to start a
4244
+ // return; pending (unpaid) and the terminal off-ramps (cancelled /
4245
+ // refunded) are not. Mirrors the operator-facing returns policy: you
4246
+ // can't return what you haven't paid for or what's already refunded.
4247
+ function _orderEligibleForReturn(status) {
4248
+ return status === "paid" || status === "fulfilling" ||
4249
+ status === "shipped" || status === "delivered";
4250
+ }
4251
+
4252
+ // Reorder is offered for any order that represents a real purchase the
4253
+ // customer might want to repeat — paid through delivered, plus the
4254
+ // terminal refunded/cancelled states (a cancelled or refunded order is a
4255
+ // perfectly good "buy this again" candidate). Only a still-pending
4256
+ // (never-paid) order is excluded, since its cart was never charged.
4257
+ function _orderEligibleForReorder(status) {
4258
+ return status !== "pending";
4259
+ }
4260
+
4261
+ // Render the lifecycle timeline. Every step up to and including the
4262
+ // current status is marked done; the current step is also marked
4263
+ // current. A terminal off-ramp (refunded / cancelled) collapses the rail
4264
+ // to a single explanatory step so we never imply a cancelled order is
4265
+ // "delivered". All labels are static framework copy (no operator input),
4266
+ // but escaped at the sink for consistency with the rest of the file.
4267
+ function _orderTimelineBlock(status) {
4268
+ var esc = b.template.escapeHtml;
4269
+ if (status === "cancelled" || status === "refunded") {
4270
+ var finalLabel = status === "cancelled" ? "Order cancelled" : "Order refunded";
4271
+ return "<div class=\"order-timeline order-timeline--terminal\">" +
4272
+ "<h2 class=\"pdp__variants-title\">Status</h2>" +
4273
+ "<ol class=\"order-timeline__steps\">" +
4274
+ "<li class=\"order-timeline__step is-current is-terminal\">" +
4275
+ "<span class=\"order-timeline__dot\" aria-hidden=\"true\"></span>" +
4276
+ "<span class=\"order-timeline__label\">" + esc(finalLabel) + "</span>" +
4277
+ "</li>" +
4278
+ "</ol></div>";
4279
+ }
4280
+ var currentIdx = -1;
4281
+ for (var i = 0; i < ORDER_TIMELINE_STEPS.length; i += 1) {
4282
+ if (ORDER_TIMELINE_STEPS[i].status === status) { currentIdx = i; break; }
4283
+ }
4284
+ var steps = ORDER_TIMELINE_STEPS.map(function (step, idx) {
4285
+ var done = currentIdx >= 0 && idx <= currentIdx;
4286
+ var current = idx === currentIdx;
4287
+ var cls = "order-timeline__step" +
4288
+ (done ? " is-done" : "") + (current ? " is-current" : "");
4289
+ return "<li class=\"" + cls + "\">" +
4290
+ "<span class=\"order-timeline__dot\" aria-hidden=\"true\"></span>" +
4291
+ "<span class=\"order-timeline__label\">" + esc(step.label) + "</span>" +
4292
+ "</li>";
4293
+ }).join("");
4294
+ return "<div class=\"order-timeline\">" +
4295
+ "<h2 class=\"pdp__variants-title\">Status</h2>" +
4296
+ "<ol class=\"order-timeline__steps\">" + steps + "</ol></div>";
4297
+ }
4298
+
4299
+ // Render the shipment + carrier-tracking panel from order-tracking's
4300
+ // listForOrder() rows. Each shipment shows its carrier, status, the
4301
+ // tracking number (linked to the carrier's public tracking URL when one
4302
+ // is known), and the most-recent carrier event. Empty/absent shipments
4303
+ // render nothing so a digital or not-yet-shipped order shows no panel.
4304
+ function _orderTrackingBlock(shipments) {
4305
+ if (!Array.isArray(shipments) || !shipments.length) return "";
4306
+ var esc = b.template.escapeHtml;
4307
+ var cards = shipments.map(function (s) {
4308
+ var carrier = s.carrier === "other"
4309
+ ? (s.carrier_other_name || "Carrier")
4310
+ : s.carrier;
4311
+ var trackingHtml = "";
4312
+ if (s.tracking_number) {
4313
+ trackingHtml = s.tracking_url
4314
+ ? "<a class=\"order-shipment__track\" href=\"" + esc(String(s.tracking_url)) +
4315
+ "\" rel=\"noopener nofollow\" target=\"_blank\">" + esc(String(s.tracking_number)) + " ↗</a>"
4316
+ : "<span class=\"order-shipment__track\">" + esc(String(s.tracking_number)) + "</span>";
4317
+ }
4318
+ // Latest carrier event (events arrive oldest-first from listForOrder's
4319
+ // getShipment ordering; the panel's per-shipment events array, when
4320
+ // hydrated, is the same order — take the last).
4321
+ var events = Array.isArray(s.events) ? s.events : [];
4322
+ var latest = events.length ? events[events.length - 1] : null;
4323
+ var latestHtml = latest
4324
+ ? "<p class=\"order-shipment__event\">" +
4325
+ esc(String(latest.status)) +
4326
+ (latest.location ? " &middot; " + esc(String(latest.location)) : "") +
4327
+ "</p>"
4328
+ : "";
4329
+ return "<li class=\"order-shipment\">" +
4330
+ "<div class=\"order-shipment__head\">" +
4331
+ "<span class=\"order-shipment__carrier\">" + esc(String(carrier)) + "</span>" +
4332
+ "<span class=\"pdp__badge\">" + esc(String(s.status)) + "</span>" +
4333
+ "</div>" +
4334
+ (trackingHtml ? "<p class=\"order-shipment__tracking\">Tracking: " + trackingHtml + "</p>" : "") +
4335
+ latestHtml +
4336
+ "</li>";
4337
+ }).join("");
4338
+ return "<div class=\"order-tracking-panel\">" +
4339
+ "<h2 class=\"pdp__variants-title\">Tracking</h2>" +
4340
+ "<ul class=\"order-shipment-list\">" + cards + "</ul></div>";
4341
+ }
4342
+
4343
+ // Request-a-return + Reorder affordances for an order, gated on its
4344
+ // status. Reorder is a POST (it mutates the cart) carrying the order id
4345
+ // in the path; Request-a-return links to the existing return form. The
4346
+ // same builder feeds the order page and (via _orderRowActions) the
4347
+ // dashboard rows so the eligibility rules live in one place.
4348
+ function _orderActionsBlock(o) {
4349
+ var esc = b.template.escapeHtml;
4350
+ var btns = [];
4351
+ if (_orderEligibleForReorder(o.status)) {
4352
+ btns.push(
4353
+ "<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/reorder\">" +
4354
+ "<button type=\"submit\" class=\"btn-secondary\">Reorder</button>" +
4355
+ "</form>");
4356
+ }
4357
+ if (_orderEligibleForReturn(o.status)) {
4358
+ btns.push(
4359
+ "<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
4360
+ }
4361
+ if (!btns.length) return "";
4362
+ return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
4363
+ }
4364
+
4225
4365
  function renderOrder(opts) {
4226
4366
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
4227
4367
  var o = opts.order;
@@ -4255,6 +4395,14 @@ function renderOrder(opts) {
4255
4395
  var shipping = pricing.format(o.shipping_minor, o.currency);
4256
4396
  var total = pricing.format(o.grand_total_minor, o.currency);
4257
4397
  var recs = opts.recommendations || [];
4398
+ // Post-handoff shipment + carrier tracking (order-tracking.listForOrder
4399
+ // rows the route bundles in; absent / empty → no panel). The lifecycle
4400
+ // timeline + the Request-a-return / Reorder affordances are derived from
4401
+ // the order status alone, so they render even without tracking wired.
4402
+ var shipments = opts.shipments || [];
4403
+ var timelineHtml = _orderTimelineBlock(o.status);
4404
+ var trackingHtml = _orderTrackingBlock(shipments);
4405
+ var actionsHtml = _orderActionsBlock(o);
4258
4406
  if (opts.theme) {
4259
4407
  return opts.theme.render("order", {
4260
4408
  title: "Order " + o.id,
@@ -4269,6 +4417,11 @@ function renderOrder(opts) {
4269
4417
  shipping: shipping,
4270
4418
  total: total,
4271
4419
  ship_to: o.ship_to || null,
4420
+ timeline_html: timelineHtml,
4421
+ tracking_html: trackingHtml,
4422
+ actions_html: actionsHtml,
4423
+ can_return: _orderEligibleForReturn(o.status),
4424
+ can_reorder: _orderEligibleForReorder(o.status),
4272
4425
  recommendations: recs,
4273
4426
  has_recommendations: recs.length > 0,
4274
4427
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -4289,6 +4442,12 @@ function renderOrder(opts) {
4289
4442
  }).replace("RAW_ORDER_LINE_THUMB", thumb);
4290
4443
  }).join("");
4291
4444
  if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No items.</td></tr>";
4445
+ // Confirmation banner after a successful reorder (the POST redirects to
4446
+ // ?reordered=1). Static copy, no untrusted input — but the cart link
4447
+ // gives the customer a one-click path to the rebuilt cart.
4448
+ var reorderNotice = opts.reordered
4449
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Items from this order were added to your cart. <a href=\"/cart\">View cart →</a></p>"
4450
+ : "";
4292
4451
  var body = _render(ORDER_PAGE, {
4293
4452
  order_id: o.id,
4294
4453
  status: o.status,
@@ -4297,7 +4456,12 @@ function renderOrder(opts) {
4297
4456
  tax: tax,
4298
4457
  shipping: shipping,
4299
4458
  total: total,
4300
- }).replace("RAW_LINES", rows).replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
4459
+ }).replace("RAW_LINES", rows)
4460
+ .replace("RAW_REORDER_NOTICE", reorderNotice)
4461
+ .replace("RAW_ORDER_TIMELINE", timelineHtml)
4462
+ .replace("RAW_ORDER_TRACKING", trackingHtml)
4463
+ .replace("RAW_ORDER_ACTIONS", actionsHtml)
4464
+ .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
4301
4465
  // Post-purchase cross-sell rail — reuses the catalog grid + product-card
4302
4466
  // markup (so it inherits the storefront's card styling), rendered only
4303
4467
  // when the picker returned something.
@@ -5186,10 +5350,11 @@ var ACCOUNT_DASH_PAGE =
5186
5350
  " <h2 class=\"pdp__variants-title\">Recent orders</h2>\n" +
5187
5351
  " <div class=\"table-scroll\">\n" +
5188
5352
  " <table class=\"account-orders-table\">\n" +
5189
- " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th></tr></thead>\n" +
5353
+ " <thead><tr><th>Order</th><th>Items</th><th>Status</th><th>Total</th><th>Actions</th></tr></thead>\n" +
5190
5354
  " <tbody>{{order_rows}}</tbody>\n" +
5191
5355
  " </table>\n" +
5192
5356
  " </div>\n" +
5357
+ " <p class=\"account-dash__all-orders\"><a href=\"/account/orders\">View all orders &rarr;</a></p>\n" +
5193
5358
  " </div>\n" +
5194
5359
  "</section>\n";
5195
5360
 
@@ -5199,6 +5364,7 @@ var ACCOUNT_DASH_ORDER_ROW =
5199
5364
  " <td class=\"account-order__items\" data-label=\"Items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
5200
5365
  " <td data-label=\"Status\"><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
5201
5366
  " <td class=\"price\" data-label=\"Total\">{{total}}</td>\n" +
5367
+ " <td data-label=\"Actions\">RAW_ACCOUNT_ORDER_ACTIONS</td>\n" +
5202
5368
  "</tr>\n";
5203
5369
 
5204
5370
  function renderAccount(opts) {
@@ -5251,15 +5417,19 @@ function renderAccount(opts) {
5251
5417
  }).join("");
5252
5418
  if (!thumbs) thumbs = "<span class=\"account-order__thumb account-order__thumb--empty\" aria-hidden=\"true\"></span>";
5253
5419
  var moreCount = products.length > 4 ? "<span class=\"account-order__more\">+" + (products.length - 4) + "</span>" : "";
5420
+ // Per-row Reorder / Request-a-return affordances (status-gated, shared
5421
+ // with the order page + order-history list). Localized to this cell —
5422
+ // it does not touch the dashboard's header action nav.
5254
5423
  return _render(ACCOUNT_DASH_ORDER_ROW, {
5255
5424
  order_id: o.id,
5256
5425
  order_id_short: o.id.slice(0, 8),
5257
5426
  status: o.status,
5258
5427
  status_class: _statusClass(o.status),
5259
5428
  total: pricing.format(o.grand_total_minor, o.currency),
5260
- }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount);
5429
+ }).replace("RAW_ACCOUNT_ORDER_THUMBS", thumbs + moreCount)
5430
+ .replace("RAW_ACCOUNT_ORDER_ACTIONS", _orderRowActions(o));
5261
5431
  }).join("");
5262
- if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No orders yet. Browse the shop and your first order shows up here.</td></tr>";
5432
+ if (!rows) rows = "<tr><td colspan=\"5\" class=\"empty\">No orders yet. Browse the shop and your first order shows up here.</td></tr>";
5263
5433
  var body = _render(ACCOUNT_DASH_PAGE, {
5264
5434
  display_name: opts.customer.display_name,
5265
5435
  order_count: String(orders.length),
@@ -5471,6 +5641,87 @@ function renderProfile(opts) {
5471
5641
  });
5472
5642
  }
5473
5643
 
5644
+ // Compact per-row Reorder / Request-a-return affordances for an order
5645
+ // table row (dashboard + order-history list). Shares the eligibility
5646
+ // rules with the order page's _orderActionsBlock so a status that offers
5647
+ // a return on the order page offers it in the row too. Reorder is a POST
5648
+ // (mutates the cart); Request-a-return is a link to the form.
5649
+ function _orderRowActions(o) {
5650
+ var esc = b.template.escapeHtml;
5651
+ var acts = [];
5652
+ if (_orderEligibleForReorder(o.status)) {
5653
+ acts.push(
5654
+ "<form method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/reorder\" class=\"order-row-action\">" +
5655
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Reorder</button>" +
5656
+ "</form>");
5657
+ }
5658
+ if (_orderEligibleForReturn(o.status)) {
5659
+ acts.push(
5660
+ "<a class=\"btn-ghost btn-ghost--sm order-row-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Return</a>");
5661
+ }
5662
+ if (!acts.length) return "<span class=\"meta\">—</span>";
5663
+ return "<div class=\"order-row-actions\">" + acts.join("") + "</div>";
5664
+ }
5665
+
5666
+ var ORDER_LIST_PAGE =
5667
+ "<section class=\"account-orders\">\n" +
5668
+ " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>\n" +
5669
+ " <li><a href=\"/account\">Account</a></li>\n" +
5670
+ " <li aria-current=\"page\">Orders</li>\n" +
5671
+ " </ol></nav>\n" +
5672
+ " <h1 class=\"account-orders__title\">Your orders</h1>\n" +
5673
+ " <div class=\"table-scroll\">\n" +
5674
+ " <table class=\"account-orders-table\">\n" +
5675
+ " <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th>Total</th><th>Actions</th></tr></thead>\n" +
5676
+ " <tbody>{{order_rows}}</tbody>\n" +
5677
+ " </table>\n" +
5678
+ " </div>\n" +
5679
+ " RAW_ORDER_LIST_PAGER" +
5680
+ "</section>\n";
5681
+
5682
+ // Full order-history list for the signed-in customer, backed by
5683
+ // order.listForCustomer (cursor-paginated). Each row links to the order
5684
+ // page and carries the per-row Reorder / Request-a-return affordances.
5685
+ // An opaque next-cursor (when the page filled) renders a "Load more"
5686
+ // link that threads ?cursor=. Empty history shows a designed empty state
5687
+ // rather than a bare table.
5688
+ function renderOrderList(opts) {
5689
+ opts = opts || {};
5690
+ var esc = b.template.escapeHtml;
5691
+ var orders = opts.orders || [];
5692
+ var rows = orders.map(function (o) {
5693
+ var placed = o.created_at ? new Date(Number(o.created_at)).toISOString().slice(0, 10) : "";
5694
+ var statusClass = (o.status === "completed" || o.status === "shipped" || o.status === "delivered")
5695
+ ? "pdp__badge--ok" : "";
5696
+ return "<tr>" +
5697
+ "<td data-label=\"Order\"><a class=\"account-order__id\" href=\"/orders/" + esc(String(o.id)) +
5698
+ "\"><code>" + esc(String(o.id).slice(0, 8)) + "</code></a></td>" +
5699
+ "<td data-label=\"Placed\">" + (placed ? "<time datetime=\"" + esc(placed) + "\">" + esc(placed) + "</time>" : "—") + "</td>" +
5700
+ "<td data-label=\"Status\"><span class=\"pdp__badge " + statusClass + "\">" + esc(String(o.status)) + "</span></td>" +
5701
+ "<td class=\"price\" data-label=\"Total\">" + esc(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
5702
+ "<td data-label=\"Actions\">" + _orderRowActions(o) + "</td>" +
5703
+ "</tr>";
5704
+ }).join("");
5705
+ var pager = "";
5706
+ if (!rows) {
5707
+ rows = "<tr><td colspan=\"5\" class=\"empty\">No orders yet. <a href=\"/\">Browse the shop →</a></td></tr>";
5708
+ } else if (opts.next_cursor) {
5709
+ pager = "<div class=\"account-orders__pager\">" +
5710
+ "<a class=\"btn-secondary\" href=\"/account/orders?cursor=" + esc(encodeURIComponent(String(opts.next_cursor))) +
5711
+ "\">Load more orders →</a></div>";
5712
+ }
5713
+ var body = _render(ORDER_LIST_PAGE, { order_rows: "RAW_ORDER_ROWS" })
5714
+ .replace("RAW_ORDER_ROWS", rows)
5715
+ .replace("RAW_ORDER_LIST_PAGER", pager);
5716
+ return _wrap({
5717
+ title: "Your orders",
5718
+ shop_name: opts.shop_name || "blamejs.shop",
5719
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5720
+ theme_css: opts.theme_css,
5721
+ body: body,
5722
+ });
5723
+ }
5724
+
5474
5725
  // ---- customer survey page ----------------------------------------------
5475
5726
 
5476
5727
  // Render one survey question as a fieldset. Rating → a 0/1..max radio
@@ -7562,10 +7813,30 @@ function mount(router, deps) {
7562
7813
  }
7563
7814
  } catch (_e) { recommendations = []; }
7564
7815
  }
7816
+ // Post-handoff shipment + carrier tracking. Best-effort: the
7817
+ // shipments table may not be migrated on every deploy, so a read
7818
+ // failure degrades to "no tracking panel" rather than 500-ing the
7819
+ // order page. getShipment hydrates each shipment's events, but
7820
+ // listForOrder doesn't — so fetch the full shipment per row to drive
7821
+ // the latest-event line. Bounded by the order's shipment count
7822
+ // (typically 1, a handful at most for split shipments).
7823
+ var shipments = [];
7824
+ if (deps.orderTracking) {
7825
+ try {
7826
+ var shipRows = await deps.orderTracking.listForOrder(o.id);
7827
+ for (var si = 0; si < shipRows.length; si += 1) {
7828
+ var full = await deps.orderTracking.getShipment(shipRows[si].id);
7829
+ shipments.push(full || shipRows[si]);
7830
+ }
7831
+ } catch (_e) { shipments = []; }
7832
+ }
7833
+ var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
7565
7834
  _send(res, 200, renderOrder({
7566
7835
  order: o,
7567
7836
  product_lookup: productLookup,
7568
7837
  recommendations: recommendations,
7838
+ shipments: shipments,
7839
+ reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
7569
7840
  shop_name: shopName,
7570
7841
  theme: theme,
7571
7842
  }));
@@ -8035,6 +8306,48 @@ function mount(router, deps) {
8035
8306
  }));
8036
8307
  });
8037
8308
 
8309
+ // Full order history for the signed-in customer, cursor-paginated.
8310
+ // The dashboard shows only the most-recent ten; this is the complete
8311
+ // list the "View your orders" link (and the returns empty-state CTA)
8312
+ // point at. Each row links to the order page + carries the per-row
8313
+ // Reorder / Request-a-return affordances. Mounted only when an order
8314
+ // handle is wired.
8315
+ if (deps.order) {
8316
+ router.get("/account/orders", async function (req, res) {
8317
+ var ordersAuth;
8318
+ try { ordersAuth = _currentCustomer(req); }
8319
+ catch (e) {
8320
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8321
+ throw e;
8322
+ }
8323
+ if (!ordersAuth) {
8324
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
8325
+ return res.end ? res.end() : res.send("");
8326
+ }
8327
+ var listUrl = req.url ? new URL(req.url, "http://localhost") : null;
8328
+ var cursor = listUrl ? listUrl.searchParams.get("cursor") : null;
8329
+ var page;
8330
+ try {
8331
+ page = await deps.order.listForCustomer(ordersAuth.customer_id, {
8332
+ limit: 20,
8333
+ cursor: cursor || undefined,
8334
+ });
8335
+ } catch (e) {
8336
+ // A tampered / malformed cursor throws TypeError — restart the
8337
+ // list from the top rather than 500-ing.
8338
+ if (!(e instanceof TypeError)) throw e;
8339
+ page = await deps.order.listForCustomer(ordersAuth.customer_id, { limit: 20 });
8340
+ }
8341
+ var listCartCount = await _cartCountForReq(req);
8342
+ _send(res, 200, renderOrderList({
8343
+ orders: page.rows,
8344
+ next_cursor: page.next_cursor || null,
8345
+ shop_name: shopName,
8346
+ cart_count: listCartCount,
8347
+ }));
8348
+ });
8349
+ }
8350
+
8038
8351
  router.post("/account/logout", function (_req, res) {
8039
8352
  _clearAuthCookie(res);
8040
8353
  res.status(303); res.setHeader && res.setHeader("location", "/");
@@ -9792,11 +10105,67 @@ function mount(router, deps) {
9792
10105
  }
9793
10106
  }
9794
10107
 
10108
+ // POST /orders/:id/reorder — rebuild a cart from a past order's frozen
10109
+ // lines. Top-level (needs only cart + order, not checkout) so the Reorder
10110
+ // affordance on the dashboard / order-history list works even on a store
10111
+ // without a payment provider configured. Login is NOT required — a guest
10112
+ // order's capability URL is the auth, mirroring the order page's own
10113
+ // ownership gate: an order that BELONGS to a customer is reorderable only
10114
+ // by that signed-in customer; a guest order (no customer_id) is
10115
+ // reorderable by anyone holding its URL. Each line's CURRENT catalog
10116
+ // price applies (cart.addLine reprices from the catalog) — never the
10117
+ // frozen historical price. Variants that no longer exist / are unbuyable
10118
+ // are skipped silently so a partly-discontinued order still reorders
10119
+ // what's still available. Eligibility gates out a never-paid pending
10120
+ // order (no captured purchase to repeat).
10121
+ if (deps.order) {
10122
+ router.post("/orders/:order_id/reorder", async function (req, res) {
10123
+ var orderId = req.params && req.params.order_id;
10124
+ var o;
10125
+ try { o = orderId ? await deps.order.get(orderId) : null; }
10126
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
10127
+ if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10128
+ var reAuth = _currentCustomerEnv(req);
10129
+ if (o.customer_id && (!reAuth || o.customer_id !== reAuth.customer_id)) {
10130
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10131
+ }
10132
+ if (!_orderEligibleForReorder(o.status)) {
10133
+ // A still-pending (never-paid) order has no captured purchase to
10134
+ // repeat — bounce back to its page rather than rebuild a cart from
10135
+ // an unpaid order.
10136
+ res.status(303);
10137
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
10138
+ return res.end ? res.end() : res.send("");
10139
+ }
10140
+ var resolved = await _getOrCreateCart(req, res, o.currency || "USD");
10141
+ var lines = o.lines || [];
10142
+ for (var rli = 0; rli < lines.length; rli += 1) {
10143
+ var line = lines[rli];
10144
+ var wantQty = Number.isInteger(line.qty) && line.qty >= 1 ? line.qty : 1;
10145
+ if (wantQty > 99) wantQty = 99;
10146
+ try {
10147
+ await deps.cart.addLine(resolved.cart.id, { variant_id: line.variant_id, qty: wantQty });
10148
+ } catch (_e) {
10149
+ // Skip a discontinued / unbuyable / out-of-stock variant rather
10150
+ // than fail the whole reorder — the customer still gets a cart of
10151
+ // everything that's still available.
10152
+ }
10153
+ }
10154
+ // Back to the order page with a confirmation banner + a cart link
10155
+ // (PRG so a refresh doesn't re-add the lines).
10156
+ res.status(303);
10157
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?reordered=1");
10158
+ return res.end ? res.end() : res.send("");
10159
+ });
10160
+ }
10161
+
9795
10162
  // POST /cart/lines — add a line. Reads variant_id + qty from the
9796
10163
  // form body (b.middleware.bodyParser parses it into req.body).
9797
- // CSRF token validation is the responsibility of the csrfProtect
9798
- // middleware mounted at the app level (server.js). Redirects to
9799
- // /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.
9800
10169
  router.post("/cart/lines", async function (req, res) {
9801
10170
  var body = req.body || {};
9802
10171
  var variantId = body.variant_id;
@@ -10054,8 +10423,10 @@ function mount(router, deps) {
10054
10423
  //
10055
10424
  // The decision is written to a sealed first-party cookie (the gate) AND
10056
10425
  // recorded in the cookie-consent ledger (the audit trail) when the
10057
- // primitive is wired. CSRF / origin / fetch-metadata defenses are the
10058
- // 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.
10059
10430
 
10060
10431
  // Same-origin path guard for `return_to`. Accepts a single leading slash
10061
10432
  // followed by a non-slash (so `//evil.example` and absolute URLs are
@@ -10332,6 +10703,7 @@ module.exports = {
10332
10703
  renderGiftCardBalance: renderGiftCardBalance,
10333
10704
  renderPayPage: renderPayPage,
10334
10705
  renderOrder: renderOrder,
10706
+ renderOrderList: renderOrderList,
10335
10707
  renderAccountLogin: renderAccountLogin,
10336
10708
  renderAccountRegister: renderAccountRegister,
10337
10709
  renderAccount: renderAccount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.21",
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": {