@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 +4 -0
- package/README.md +1 -1
- package/lib/admin.js +332 -15
- package/lib/asset-manifest.json +1 -1
- package/lib/index.js +1 -0
- package/lib/security-middleware.js +217 -0
- package/lib/storefront.js +382 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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
|
-
|
|
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
|
-
|
|
2020
|
+
recorded = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
|
|
1812
2021
|
} catch (e) {
|
|
1813
|
-
var
|
|
1814
|
-
if (
|
|
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,
|
|
1818
|
-
return
|
|
2026
|
+
_json(res, 200, recorded);
|
|
2027
|
+
return recorded;
|
|
1819
2028
|
}),
|
|
1820
|
-
|
|
1821
|
-
|
|
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 & 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\">← 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
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
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(
|
package/lib/asset-manifest.json
CHANGED
package/lib/index.js
CHANGED
|
@@ -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
|
|
2816
|
+
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</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 ? " · " + 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)
|
|
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 →</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=\"
|
|
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
|
-
//
|
|
9798
|
-
//
|
|
9799
|
-
//
|
|
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.
|
|
10058
|
-
//
|
|
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