@blamejs/blamejs-shop 0.3.71 → 0.3.73

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.3.x
10
10
 
11
+ - v0.3.73 (2026-06-05) — **Unlock codes are manageable from the Discounts screen, and coupon guessing is rate-capped.** Code-unlocked discount rules shipped with an API-only gap: the Discounts console had no field for the unlock code, so creating a code-gated rule required a raw API call. The create and edit forms now carry an optional Unlock code input — clearing it on edit reverts the rule to purely automatic — and the rule list and detail show which rules are code-gated; the screen's description covers both kinds. The cart's code-apply endpoint joins the tight per-address rate budget that already guards gift-card balance lookups, capping coupon-namespace guessing at the same rate. Also fixed: a failed confirmation-resend in the browser console now lands in the error log with a clean notice instead of an unrecorded failure, and the signed-in cart page resolves the shopper's destination once instead of twice per view. **Fixed:** *Unlock codes editable in the Discounts console* — The create and edit forms gain an optional Unlock code field, threaded through the same validation as the API. On edit, submitting the field blank explicitly clears the code (the rule becomes purely automatic again), while the inline quick-edit leaves it untouched. The rule list and the detail view display each rule's code, escaped, so code-gated rules are visible at a glance, and the screen copy now describes both automatic and code-unlocked rules. · *Coupon-code guessing joins the tight rate budget* — POST /cart/coupon now sits in the per-address, per-path rate budget alongside gift-card balance lookups — both accept guessable secrets and answer uniformly, so both deserve the same throttle. The pinned integration test sprays the endpoint and asserts the cap engages. · *Failed confirmation resends are captured and surfaced* — A mailer fault during a browser-initiated confirmation resend previously escaped both the error log and the screen. It now records to the error log and redirects back to the order with an honest failure notice; the API path captures identically. The signed-in cart view also drops a duplicated destination lookup per render.
12
+
13
+ - v0.3.72 (2026-06-05) — **Resend an order confirmation from the console, and export a segment's members as CSV.** Two operator actions land in the admin console. The order detail screen gains a Resend-confirmation action: buyer emails are stored only as one-way hashes, so the original address cannot be recovered — the operator types the recipient (from the customer's own request), and a fresh receipt rendered from the stored order is sent, rate-bounded to three per order per hour, with every send audited and the recipient kept out of the audit trail. The action appears only when a mailer is configured. Customer segments gain a members CSV export that streams id, display name, segment join date, and order count — deliberately no email column, since addresses are not stored in readable form; the file says so in a leading comment, cell values are quoted and spreadsheet-formula-neutralized, and the stream is written batch by batch so a large segment never buffers in memory. **Added:** *Resend order confirmation* — POST /admin/orders/:id/resend-confirmation sends a fresh receipt for the stored order to an operator-supplied address, since the buyer's email exists only as a hash and cannot be recovered — the recipient comes from the customer's own "I didn't get it" request. Rate-bounded to three sends per order per hour, audited without recording the recipient, gated on a configured mailer (the panel renders an honest disabled note otherwise), and validated for address shape. · *Segment members CSV export* — GET /admin/segments/:slug/members.csv streams a segment's members — customer id, display name, join date, order count — in keyset-paginated batches with RFC 4180 quoting and spreadsheet-formula-injection neutralization. There is no email column: addresses are stored hashed, and both the screen copy and a leading CSV comment state it. Unknown segments return 404 before any bytes stream; an archived segment yields a well-formed header-only file; each export is audited.
14
+
11
15
  - v0.3.71 (2026-06-05) — **Vendored blamejs framework refreshed from v0.14.19 to v0.14.21.** The storefront runs on a vendored copy of the blamejs framework; this refreshes it across two upstream patch releases. The most operator-relevant upstream changes for this shop: the OAuth client — which Sign in with Google and Apple compose — gains RFC 9396 Rich Authorization Request validation and attestation-based client authentication primitives, and refuses token grants an identity provider broadened beyond what was requested; HEAD requests now conform by carrying no response body; the sealed-field store gains an unseal rate cap; and a framework-wide sweep ensures every accepted option is actually read, surfacing configuration typos that were previously silent. The remaining upstream changes are in framework areas the storefront does not expose (SCIM bulk operations, OID4VCI credential proofs, DMARC forensic-report parsing). The storefront's own behavior is unchanged, verified by the full test suite — including the regression guards that pin the CSRF origin posture, the referrer policy, and the payment-processor TLS agent against vendor drift — and the vendored-tree integrity manifest was re-stamped as part of the refresh. **Changed:** *Updated the vendored framework to blamejs v0.14.21* — The vendored framework moves from v0.14.19 to v0.14.21 (two upstream patch releases of fixes and additive, opt-in changes). Notable for this shop: hardened OAuth client validation behind federated sign-in, HTTP HEAD conformance, a sealed-field unseal rate cap, and boot-time validation of previously-unread options. The integrity manifest over the vendored tree was re-stamped as part of the refresh.
12
16
 
13
17
  - v0.3.70 (2026-06-05) — **An Analytics screen in the admin console: funnel, search terms, and product views.** The admin console gains a read-only Analytics screen at /admin/analytics showing the pre-purchase signal the sales report cannot see: the browse-to-buy funnel (product views to cart adds to checkout starts to completed orders) with a conversion rate, top search terms, most-viewed products, top SKUs ranked by units sold, and a revenue-by-day sparkline. It complements rather than duplicates the existing Reports screen — revenue totals stay there, and the two screens link to each other. The same path answers a bearer-token request with JSON, and three JSON endpoints expose the funnel, search terms, and viewed products individually for tooling. Every aggregate is window-bounded (one year maximum, thirty-day default) and limit-bounded; the screen adds no write path anywhere. **Added:** *Read-only analytics dashboard* — The analytics primitive's aggregates are now wired into the console: a date-windowed view with the conversion funnel, top search terms, most-viewed products, units-ranked top SKUs, recent revenue trend, and cross-links with the Reports screen. The HTML view degrades a malformed date window to the default with a notice; the JSON surfaces reject it with a 400. Limits clamp to the primitive's own bounds, a refund-heavy window renders a signed negative total instead of failing, and every operator- or customer-derived string on the screen is escaped. The screen appears in the navigation only when the primitive is wired.
package/README.md CHANGED
@@ -97,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
97
97
  | **`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). |
98
98
  | **`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. |
99
99
  | **`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. |
100
- | **`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 — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, and Errors 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. |
100
+ | **`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, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), 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); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **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 — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, and Errors 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. |
101
101
  | **`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. |
102
102
  | **`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. |
103
103
 
package/lib/admin.js CHANGED
@@ -524,6 +524,7 @@ function mount(router, deps) {
524
524
  var catalog = deps.catalog;
525
525
  var order = deps.order;
526
526
  var payment = deps.payment || null; // refund endpoints disabled when absent
527
+ var mailer = deps.mailer || null; // transactional email factory (lib/email.js) — resend-confirmation disabled when absent
527
528
  var _checkout = deps.checkout || null; // reserved — future webhook handler wiring
528
529
  var r2 = deps.r2_bridge || null; // media-upload endpoint disabled when absent
529
530
  var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
@@ -1781,6 +1782,14 @@ function mount(router, deps) {
1781
1782
  // wired (its route is mounted only then).
1782
1783
  can_receipt: !!printReceipts,
1783
1784
  can_packing_slip: !!packingSlips,
1785
+ // Resend the order-confirmation email. The route mounts only when a
1786
+ // transactional mailer is wired; absent one the panel renders a
1787
+ // disabled note (mailer-gated, like the document links). The buyer's
1788
+ // plaintext address is never stored (the order keeps only the email
1789
+ // hash), so the form takes an operator-supplied recipient.
1790
+ can_resend_confirmation: !!mailer,
1791
+ resent: url && url.searchParams.get("resent"),
1792
+ resend_error: url && url.searchParams.get("resend_err"),
1784
1793
  // Per-shipment carrier-label record form + split-shipment planner —
1785
1794
  // each renders only when its primitive is wired (routes mount only
1786
1795
  // then). Label carrier/package/broker enums drive the form selects.
@@ -2744,6 +2753,163 @@ function mount(router, deps) {
2744
2753
  },
2745
2754
  ));
2746
2755
 
2756
+ // ---- resend order confirmation --------------------------------------
2757
+ //
2758
+ // Re-send the order-receipt email a customer asks for ("I never got my
2759
+ // confirmation"). The original receipt is sent by the payment provider
2760
+ // at confirm time (Stripe `receipt_email`) using the plaintext address
2761
+ // the buyer typed — that address is NEVER persisted: the order row keeps
2762
+ // only `customer_email_hash`, and the customers table keeps only
2763
+ // `email_hash`. A one-way hash can't be reversed into a deliverable
2764
+ // address, so this action can't recover the original recipient. It
2765
+ // therefore takes the recipient the OPERATOR supplies (they have it from
2766
+ // the support request) and sends a fresh receipt rendered from the stored
2767
+ // order totals. The email-hashing stance is unchanged — we never
2768
+ // reconstruct an address from a hash.
2769
+ //
2770
+ // Mounted only when a transactional mailer is wired (`deps.mailer`,
2771
+ // lib/email.js). Absent one the detail panel shows a disabled note.
2772
+ //
2773
+ // Rate-bound per order via the audit chain itself — no new column. Before
2774
+ // sending, we count this order's prior `order.receipt.resend` audit rows
2775
+ // inside the trailing hour; past the cap the send is refused. The audit
2776
+ // chain is append-only + tamper-evident, so the counter can't be quietly
2777
+ // reset.
2778
+ if (mailer && typeof mailer.orderReceipt === "function") {
2779
+ var RESEND_WINDOW_MS = b.constants.TIME.hours(1);
2780
+ var RESEND_MAX_PER_HR = 3;
2781
+ // Per-order sliding window of recent resend timestamps, scoped to this
2782
+ // admin mount. A plain in-memory map keyed by order id — the rate bound
2783
+ // is a soft abuse guard (stop an operator hammering "resend" at a
2784
+ // customer's inbox), not a security control, so an in-process window is
2785
+ // the right weight: no audit-chain read on the hot path, deterministic
2786
+ // under burst, and a process restart simply clears the soft window
2787
+ // (every resend is still durably recorded in the audit chain for
2788
+ // forensics). Stale order entries are pruned lazily on each check so the
2789
+ // map can't grow unbounded.
2790
+ var _resendWindow = Object.create(null);
2791
+
2792
+ function _recentResendCount(orderId, nowTs) {
2793
+ var cutoff = nowTs - RESEND_WINDOW_MS;
2794
+ var arr = _resendWindow[orderId];
2795
+ if (!arr) return 0;
2796
+ var kept = [];
2797
+ for (var i = 0; i < arr.length; i += 1) {
2798
+ if (arr[i] > cutoff) kept.push(arr[i]);
2799
+ }
2800
+ if (kept.length) _resendWindow[orderId] = kept;
2801
+ else delete _resendWindow[orderId];
2802
+ return kept.length;
2803
+ }
2804
+
2805
+ function _recordResendTimestamp(orderId, nowTs) {
2806
+ var arr = _resendWindow[orderId] || (_resendWindow[orderId] = []);
2807
+ arr.push(nowTs);
2808
+ }
2809
+
2810
+ // Resolve the display name for the receipt greeting. A linked customer
2811
+ // record carries a display_name; a guest order has none. The plaintext
2812
+ // email is operator-supplied (never stored), so only the name is read
2813
+ // from the order's customer link.
2814
+ async function _receiptName(o) {
2815
+ if (!o.customer_id || !customers) return "there";
2816
+ try {
2817
+ var c = await customers.get(o.customer_id);
2818
+ return (c && c.display_name) || "there";
2819
+ } catch (_e) { return "there"; }
2820
+ }
2821
+
2822
+ // Send the receipt for an order to an operator-supplied address. Throws
2823
+ // a TypeError on a bad address / rate-limit refusal so both the JSON and
2824
+ // browser paths can map it to a clean 4xx / err redirect; any genuine
2825
+ // mailer fault propagates.
2826
+ async function _resendReceipt(o, toRaw) {
2827
+ var to = typeof toRaw === "string" ? toRaw.trim() : "";
2828
+ if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to) || to.length > 254) {
2829
+ var e = new TypeError("admin.order.resend: a valid recipient email is required");
2830
+ e.code = "RESEND_BAD_EMAIL";
2831
+ throw e;
2832
+ }
2833
+ var nowTs = Date.now();
2834
+ var recent = _recentResendCount(o.id, nowTs);
2835
+ if (recent >= RESEND_MAX_PER_HR) {
2836
+ var rl = new TypeError("admin.order.resend: resend limit reached for this order — try again later");
2837
+ rl.code = "RESEND_RATE_LIMITED";
2838
+ throw rl;
2839
+ }
2840
+ // Reserve the slot BEFORE awaiting the mailer: concurrent resend
2841
+ // POSTs for the same order would otherwise all read the same
2842
+ // pre-send count and pass the limit together while the first send
2843
+ // is still in flight. A mailer failure releases the reservation so
2844
+ // a failed send doesn't consume the operator's budget.
2845
+ _recordResendTimestamp(o.id, nowTs);
2846
+ try {
2847
+ var name = await _receiptName(o);
2848
+ await mailer.orderReceipt({ order: o, customer: { email: to, name: name } });
2849
+ } catch (sendErr) {
2850
+ var arr = _resendWindow[o.id];
2851
+ if (arr) {
2852
+ var idx = arr.indexOf(nowTs);
2853
+ if (idx !== -1) arr.splice(idx, 1);
2854
+ if (!arr.length) delete _resendWindow[o.id];
2855
+ }
2856
+ throw sendErr;
2857
+ }
2858
+ return { to: to };
2859
+ }
2860
+
2861
+ // The send is audited via the hot-path `safeEmit` (drop-silent, like
2862
+ // every other admin mutation) — the recipient is operator-supplied PII
2863
+ // and is deliberately kept OUT of the metadata; the row records only
2864
+ // that a resend happened for this order id.
2865
+ router.post("/admin/orders/:id/resend-confirmation", _pageOrApi(false,
2866
+ W("order.receipt.resend", async function (req, res) {
2867
+ var o = await order.get(req.params.id);
2868
+ if (!o) return _problem(res, 404, "order-not-found");
2869
+ var body = req.body || {};
2870
+ try { await _resendReceipt(o, body.email || body.to); }
2871
+ catch (e) {
2872
+ if (e instanceof TypeError) {
2873
+ var code = e.code === "RESEND_RATE_LIMITED" ? 429 : 400;
2874
+ return _problem(res, code, e.code === "RESEND_RATE_LIMITED" ? "resend-rate-limited" : "bad-request", e.message);
2875
+ }
2876
+ throw e;
2877
+ }
2878
+ _json(res, 200, { id: o.id, resent: true });
2879
+ return { id: o.id };
2880
+ }),
2881
+ async function (req, res) {
2882
+ var id = req.params.id;
2883
+ var enc = encodeURIComponent(id);
2884
+ var o;
2885
+ try { o = await order.get(id); }
2886
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
2887
+ if (!o) return _sendHtml(res, 404, renderAdminOrders({
2888
+ shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
2889
+ }));
2890
+ var body = req.body || {};
2891
+ try { await _resendReceipt(o, body.email || body.to); }
2892
+ catch (e) {
2893
+ // Validation / rate-limit faults are TypeErrors → a contextual
2894
+ // banner. A mailer fault (the send itself failing) is NOT a
2895
+ // TypeError: route it through _safeNotice so it lands in the
2896
+ // operator error log (the htmlHandler isn't wrapped by _wrap, so
2897
+ // a re-throw here would bypass the capture the JSON path gets via
2898
+ // the wrapper's catch), then redirect to a generic send-failed
2899
+ // banner rather than 500-ing the page.
2900
+ if (e instanceof TypeError) {
2901
+ var reason = e.code === "RESEND_RATE_LIMITED" ? "rate" : "email";
2902
+ return _redirect(res, "/admin/orders/" + enc + "?resend_err=" + reason);
2903
+ }
2904
+ _safeNotice(e, "order.receipt.resend");
2905
+ return _redirect(res, "/admin/orders/" + enc + "?resend_err=send");
2906
+ }
2907
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.receipt.resend", outcome: "success", metadata: { id: id } });
2908
+ _redirect(res, "/admin/orders/" + enc + "?resent=1");
2909
+ },
2910
+ ));
2911
+ }
2912
+
2747
2913
  // ---- customers (read-only) ------------------------------------------
2748
2914
 
2749
2915
  // Operator-facing customer roster, newest first. READ-ONLY — no create /
@@ -9748,6 +9914,62 @@ function mount(router, deps) {
9748
9914
  _redirect(res, "/admin/segments?unarchived=1");
9749
9915
  },
9750
9916
  ));
9917
+
9918
+ // Members CSV export — stream a segment's members for a marketing
9919
+ // handoff. The primitive yields a leading comment row + header, then
9920
+ // one chunk per membership batch (keyset-paginated, bounded memory);
9921
+ // the route writes each batch to the socket as it's produced, never
9922
+ // buffering the whole body — the download-route discipline. The CSV
9923
+ // carries NO email column (addresses are stored only as a one-way
9924
+ // hash and are never reconstructed). Audited as an export event.
9925
+ // Same response on the bearer + browser surfaces (a link, not a fetch).
9926
+ async function _streamSegmentMembers(res, slug) {
9927
+ var iter = customerSegments.membersCsvForSegment(slug);
9928
+ res.status(200);
9929
+ if (res.setHeader) {
9930
+ res.setHeader("content-type", "text/csv; charset=utf-8");
9931
+ var fname = ("segment-" + slug + "-members.csv").replace(/[^A-Za-z0-9._-]/g, "");
9932
+ res.setHeader("content-disposition", "attachment; filename=\"" + fname + "\"");
9933
+ res.setHeader("x-content-type-options", "nosniff");
9934
+ }
9935
+ // Write the header chunk first, then one chunk per batch. A response
9936
+ // without an incremental write() (a JSON test stub) buffers instead.
9937
+ if (typeof res.write === "function" && typeof res.end === "function") {
9938
+ for await (var chunk of iter) { if (chunk) res.write(chunk); }
9939
+ res.end();
9940
+ } else {
9941
+ var body = "";
9942
+ for await (var c2 of iter) body += c2;
9943
+ if (res.end) res.end(body); else res.send(body);
9944
+ }
9945
+ }
9946
+
9947
+ // Audit the export as a data-egress event before streaming. Hot-path
9948
+ // `safeEmit` (drop-silent) like every other admin mutation — a recording
9949
+ // hiccup must never block the operator's legitimate download.
9950
+ function _auditExport(slug) {
9951
+ b.audit.safeEmit({
9952
+ action: AUDIT_NAMESPACE + ".customer_segment.export",
9953
+ outcome: "success",
9954
+ metadata: { slug: slug },
9955
+ });
9956
+ }
9957
+
9958
+ router.get("/admin/segments/:slug/members.csv", _pageOrApi(true,
9959
+ R(async function (req, res) {
9960
+ var seg = await _segmentBySlug(req.params.slug);
9961
+ if (!seg) return _problem(res, 404, "customer-segment-not-found");
9962
+ _auditExport(seg.slug);
9963
+ return _streamSegmentMembers(res, seg.slug);
9964
+ }),
9965
+ async function (req, res) {
9966
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9967
+ var seg = await _segmentBySlug(req.params.slug);
9968
+ if (!seg) return _sendHtml(res, 404, await _renderSegments({ url: url, notice: "Segment not found." }));
9969
+ _auditExport(seg.slug);
9970
+ return _streamSegmentMembers(res, seg.slug);
9971
+ },
9972
+ ));
9751
9973
  }
9752
9974
 
9753
9975
  // ---- quantity discounts ---------------------------------------------
@@ -10556,6 +10778,13 @@ function mount(router, deps) {
10556
10778
  if (body.priority != null && body.priority !== "") {
10557
10779
  input.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
10558
10780
  }
10781
+ // Optional shopper-typed unlock code. A non-empty value makes the rule
10782
+ // code-gated (dormant until presented at /cart/coupon); the lib
10783
+ // validates the code shape and rejects a clash with another active
10784
+ // rule's code. Blank leaves it a pure automatic.
10785
+ if (typeof body.unlock_code === "string" && body.unlock_code.trim() !== "") {
10786
+ input.unlock_code = body.unlock_code.trim();
10787
+ }
10559
10788
  return input;
10560
10789
  }
10561
10790
 
@@ -10623,6 +10852,14 @@ function mount(router, deps) {
10623
10852
  if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
10624
10853
  if (body.trigger_kind != null && body.trigger_kind !== "") patch.trigger = _discountTrigger(body);
10625
10854
  if (body.value_kind != null && body.value_kind !== "") patch.value = _discountValue(body);
10855
+ // The edit form always renders the unlock-code field with a hidden
10856
+ // `unlock_code_present` marker, so a blank submission is an explicit
10857
+ // CLEAR (the lib maps "" → null, reverting the rule to a pure
10858
+ // automatic) rather than an omission. The lib validates the shape and
10859
+ // rejects a code already claimed by another active rule.
10860
+ if (body.unlock_code_present === "1") {
10861
+ patch.unlock_code = typeof body.unlock_code === "string" ? body.unlock_code.trim() : "";
10862
+ }
10626
10863
  return patch;
10627
10864
  }
10628
10865
 
@@ -11948,6 +12185,14 @@ function renderAdminOrder(opts) {
11948
12185
  var shipOk = opts.ship_done ? "<div class=\"banner banner--ok\">Shipment updated.</div>" : "";
11949
12186
  var labelOk = opts.label_done ? "<div class=\"banner banner--ok\">Shipping label updated.</div>" : "";
11950
12187
  var splitOk = opts.split_done ? "<div class=\"banner banner--ok\">Split shipment updated.</div>" : "";
12188
+ var resentOk = opts.resent ? "<div class=\"banner banner--ok\">Confirmation email resent.</div>" : "";
12189
+ var resendErr = opts.resend_error === "rate"
12190
+ ? "<div class=\"banner banner--err\">Resend limit reached for this order. Try again later.</div>"
12191
+ : (opts.resend_error === "email"
12192
+ ? "<div class=\"banner banner--err\">Enter a valid recipient email address to resend.</div>"
12193
+ : (opts.resend_error === "send"
12194
+ ? "<div class=\"banner banner--err\">The confirmation email couldn't be sent. The failure was logged — try again, or check the error log.</div>"
12195
+ : ""));
11951
12196
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11952
12197
 
11953
12198
  var lineRows = (o.lines || []).map(function (l) {
@@ -12019,6 +12264,28 @@ function renderAdminOrder(opts) {
12019
12264
  "</div>"
12020
12265
  : "";
12021
12266
 
12267
+ // Resend confirmation panel. The buyer's plaintext address is never
12268
+ // stored (the order keeps only the email hash), so the operator types
12269
+ // the recipient — typically the address from the customer's "I didn't
12270
+ // get my confirmation" request. Rendered only when a mailer is wired;
12271
+ // absent one it's a disabled note (mailer-gated, like the print links).
12272
+ var resendPanel;
12273
+ if (opts.can_resend_confirmation) {
12274
+ resendPanel =
12275
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Resend confirmation</h3>" +
12276
+ "<p class=\"meta\">The original receipt was sent to the address the customer entered at checkout. That address isn't stored (only a one-way hash is), so enter where to send a fresh copy.</p>" +
12277
+ "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/resend-confirmation\">" +
12278
+ _setupField("Recipient email", "email", "", "email", "Where to send the order receipt.", " maxlength=\"254\" required") +
12279
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Resend confirmation</button></div>" +
12280
+ "</form>" +
12281
+ "</div>";
12282
+ } else {
12283
+ resendPanel =
12284
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Resend confirmation</h3>" +
12285
+ "<p class=\"empty\">Email isn't configured for this store, so confirmations can't be resent. Wire a transactional mailer to enable this.</p>" +
12286
+ "</div>";
12287
+ }
12288
+
12022
12289
  // Shipment + tracking panel. Renders only when the tracking primitive is
12023
12290
  // wired (`can_track`). For each existing shipment: the carrier, status,
12024
12291
  // tracking number (linked to the carrier's public URL when known), and
@@ -12113,7 +12380,7 @@ function renderAdminOrder(opts) {
12113
12380
  "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
12114
12381
  "<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
12115
12382
  (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
12116
- moved + shipOk + labelOk + splitOk + notice +
12383
+ moved + shipOk + labelOk + splitOk + resentOk + resendErr + notice +
12117
12384
  "<div class=\"two-col\">" +
12118
12385
  "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
12119
12386
  "<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
@@ -12125,6 +12392,7 @@ function renderAdminOrder(opts) {
12125
12392
  "<div class=\"order-actions\">" + actions + "</div>" +
12126
12393
  "</div>" +
12127
12394
  documentsPanel +
12395
+ resendPanel +
12128
12396
  splitPanel +
12129
12397
  trackingPanel +
12130
12398
  "</section>";
@@ -14836,6 +15104,8 @@ function renderAdminDiscount(opts) {
14836
15104
  _setupField("· BOGO buy qty", "value_buy_qty", v.kind === "bogo" && v.buy_qty != null ? String(v.buy_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
14837
15105
  _setupField("· BOGO get qty", "value_get_qty", v.kind === "bogo" && v.get_qty != null ? String(v.get_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
14838
15106
  _setupField("Priority", "priority", String(r.priority), "number", "Higher wins ties.", " min=\"0\"") +
15107
+ "<input type=\"hidden\" name=\"unlock_code_present\" value=\"1\">" +
15108
+ _setupField("Unlock code (optional)", "unlock_code", r.unlock_code || "", "text", "Code that gates this rule — shoppers type it at the cart to unlock it. Clear the field to make the rule a pure automatic again. Letters, digits, . _ - only.", " maxlength=\"64\"") +
14839
15109
  "<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> Active</label>" +
14840
15110
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
14841
15111
  "</form>" +
@@ -14846,6 +15116,7 @@ function renderAdminDiscount(opts) {
14846
15116
  "<div><dt>Rule</dt><dd><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></dd></div>" +
14847
15117
  "<div><dt>Trigger</dt><dd>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</dd></div>" +
14848
15118
  "<div><dt>Value</dt><dd>" + _htmlEscape(_fmtValue(r.value)) + "</dd></div>" +
15119
+ "<div><dt>Unlock code</dt><dd>" + (r.unlock_code ? "<code class=\"order-id\">" + _htmlEscape(r.unlock_code) + "</code> <span class=\"meta\">(shopper types this at the cart)</span>" : "<span class=\"meta\">none — pure automatic</span>") + "</dd></div>" +
14849
15120
  "<div><dt>Priority</dt><dd>" + _htmlEscape(String(r.priority)) + "</dd></div>" +
14850
15121
  "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : (r.active ? "paid" : "pending")) + "\">" + (isArchived ? "archived" : (r.active ? "active" : "paused")) + "</span></dd></div>" +
14851
15122
  "</dl></div>" +
@@ -14880,8 +15151,15 @@ function renderAdminDiscounts(opts) {
14880
15151
  "<a class=\"btn btn--ghost\" href=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "\">Edit terms</a> " +
14881
15152
  "<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/archive\" class=\"form-inline\">" +
14882
15153
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
15154
+ // A code-gated rule is dormant until a shopper types its unlock code at
15155
+ // the cart; surface the code so an operator can see at a glance which
15156
+ // rules are gated vs pure-automatic. The code is operator-authored free
15157
+ // text → escaped at the sink.
15158
+ var gate = r.unlock_code
15159
+ ? "<br><span class=\"meta\">code: <code class=\"order-id\">" + _htmlEscape(r.unlock_code) + "</code></span>"
15160
+ : "";
14883
15161
  return "<tr>" +
14884
- "<td><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></td>" +
15162
+ "<td><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code>" + gate + "</td>" +
14885
15163
  "<td>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</td>" +
14886
15164
  "<td>" + _htmlEscape(_fmtValue(r.value)) + "</td>" +
14887
15165
  "<td class=\"num\">" + _htmlEscape(String(r.priority)) + "</td>" +
@@ -14927,6 +15205,7 @@ function renderAdminDiscounts(opts) {
14927
15205
  _setupField("· BOGO buy qty", "value_buy_qty", "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
14928
15206
  _setupField("· BOGO get qty", "value_get_qty", "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
14929
15207
  _setupField("Priority", "priority", "", "number", "Higher wins ties. Default 0.", " min=\"0\"") +
15208
+ _setupField("Unlock code (optional)", "unlock_code", "", "text", "Leave blank for a pure automatic. Set a code to gate the rule — it stays dormant until a shopper types this code at the cart. Letters, digits, . _ - only.", " maxlength=\"64\"") +
14930
15209
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add rule</button></div>" +
14931
15210
  "</form>" +
14932
15211
  "</div>";
@@ -14974,7 +15253,7 @@ function renderAdminDiscounts(opts) {
14974
15253
  }
14975
15254
 
14976
15255
  var body = "<section><h2>Discounts</h2>" + created + updated + archived + notice +
14977
- "<p class=\"meta\">Automatic cart-level discounts applied without a coupon code.</p>" +
15256
+ "<p class=\"meta\">Cart-level discount rules. By default a rule applies automatically when the cart matches its trigger; set an unlock code on a rule to keep it dormant until a shopper types that code at the cart.</p>" +
14978
15257
  ruleTable + createForm + policySection + "</section>";
14979
15258
  return _renderAdminShell(opts.shop_name, "Discounts", body, "discounts", opts.nav_available);
14980
15259
  }
@@ -15047,6 +15326,7 @@ function renderAdminSegments(opts) {
15047
15326
  "<button class=\"btn btn--ghost\" type=\"submit\">Unarchive</button></form>"
15048
15327
  : "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/recompute\" class=\"form-inline\">" +
15049
15328
  "<button class=\"btn btn--ghost\" type=\"submit\">Recompute</button></form> " +
15329
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments/" + _htmlEscape(enc) + "/members.csv\">Export CSV</a> " +
15050
15330
  "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
15051
15331
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>");
15052
15332
  return "<tr>" +
@@ -15134,6 +15414,15 @@ function renderAdminSegment(opts) {
15134
15414
  "<button class=\"btn\" type=\"submit\">Recompute membership</button>" +
15135
15415
  "</form>";
15136
15416
 
15417
+ // Members CSV export — for a marketing handoff. Active segments only
15418
+ // (an archived segment's membership cache is cleared by contract). The
15419
+ // export carries no email addresses (stored as a one-way hash); the
15420
+ // note states this so the operator knows why.
15421
+ var exportLink = isArchived ? "" :
15422
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments/" + _htmlEscape(enc) + "/members.csv\">Export members (CSV)</a>";
15423
+ var exportNote = isArchived ? "" :
15424
+ "<p class=\"meta\">The members CSV lists customer id, display name, join date, and order count — no email addresses (they're stored only as a one-way hash and aren't reconstructed). Recompute first if the membership looks stale.</p>";
15425
+
15137
15426
  var editForm = isArchived
15138
15427
  ? "<p class=\"empty\">This segment is archived. Unarchive it to edit or recompute.</p>" +
15139
15428
  "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
@@ -15158,7 +15447,8 @@ function renderAdminSegment(opts) {
15158
15447
 
15159
15448
  var body = "<section><h2>" + _htmlEscape(s.title) + "</h2>" + updated + recomputed + notice +
15160
15449
  head + statCards +
15161
- "<div class=\"actions-row mt\">" + recomputeForm + archiveForm + "</div>" +
15450
+ "<div class=\"actions-row mt\">" + recomputeForm + exportLink + archiveForm + "</div>" +
15451
+ exportNote +
15162
15452
  editForm +
15163
15453
  "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/segments\">Back to segments</a></div>" +
15164
15454
  "</section>";
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.71",
2
+ "version": "0.3.73",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -113,6 +113,21 @@ var C = b.constants;
113
113
 
114
114
  var DEFAULT_LIMIT = 100;
115
115
  var MAX_LIMIT = 1000;
116
+ var EXPORT_BATCH_SIZE = 500;
117
+
118
+ // CSV column shape for a segment-members export. NO email column —
119
+ // customer email is stored only as a one-way hash, so a marketing
120
+ // export reconstructs no addresses; the operator gets a stable
121
+ // customer id, the display name, the join date (when the customer
122
+ // landed in the segment), and the lifetime order count. The screen
123
+ // copy + the leading comment row state this so the operator knows why
124
+ // the address is absent.
125
+ var MEMBER_EXPORT_COLUMNS = Object.freeze([
126
+ "customer_id",
127
+ "display_name",
128
+ "joined_segment_at",
129
+ "order_count",
130
+ ]);
116
131
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
117
132
  var MAX_TITLE_LEN = 200;
118
133
  var MAX_DESC_LEN = 1000;
@@ -314,6 +329,42 @@ function _limit(n, label) {
314
329
 
315
330
  function _now() { return Date.now(); }
316
331
 
332
+ // ---- CSV helpers (members export) ---------------------------------------
333
+ //
334
+ // Same RFC-4180 quoting + spreadsheet-formula-injection neutralization
335
+ // the order-export primitive uses: every cell is quoted, embedded `"`
336
+ // doubled, and any cell beginning with `=` / `+` / `-` / `@` is prefixed
337
+ // with `'` so a spreadsheet treats it as text (a customer-controlled
338
+ // display_name is the injection vector). Signed numerics pass through.
339
+
340
+ function _coerceCell(value) {
341
+ if (value == null) return "";
342
+ if (typeof value === "string") return value;
343
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
344
+ return JSON.stringify(value);
345
+ }
346
+
347
+ var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
348
+
349
+ function _neutralizeInjection(s) {
350
+ if (s.length === 0) return s;
351
+ var first = s.charAt(0);
352
+ if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
353
+ if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
354
+ return "'" + s;
355
+ }
356
+
357
+ function _csvCell(value) {
358
+ var s = _neutralizeInjection(_coerceCell(value));
359
+ return '"' + s.replace(/"/g, '""') + '"';
360
+ }
361
+
362
+ function _csvRow(cells) {
363
+ var parts = [];
364
+ for (var i = 0; i < cells.length; i += 1) parts.push(_csvCell(cells[i]));
365
+ return parts.join(",") + "\r\n";
366
+ }
367
+
317
368
  // ---- factory ------------------------------------------------------------
318
369
 
319
370
  function create(opts) {
@@ -615,6 +666,100 @@ function create(opts) {
615
666
  return { customer_ids: ids, next_cursor: nextCursor };
616
667
  },
617
668
 
669
+ MEMBER_EXPORT_COLUMNS: MEMBER_EXPORT_COLUMNS.slice(),
670
+
671
+ // Streaming CSV of a segment's members for a marketing handoff.
672
+ // Async-iterable: yields a leading comment row + the header on the
673
+ // first iteration, then one chunk per membership batch. Keyset-
674
+ // paginated on customer_id ASC (the membership PK order) so the
675
+ // process holds at most one batch in memory regardless of segment
676
+ // size — mirrors the order-export streaming discipline.
677
+ //
678
+ // Columns: customer_id, display_name, joined_segment_at (the
679
+ // recompute timestamp that placed the customer in the segment),
680
+ // order_count (lifetime non-cancelled orders). There is NO email
681
+ // column — the address is stored only as a one-way hash and is never
682
+ // reconstructed; the leading comment row states this so the operator
683
+ // understands the omission.
684
+ //
685
+ // An unknown / archived slug yields an iterator whose first `next()`
686
+ // throws a TypeError, so the HTTP layer can map it to a clean 404
687
+ // without a partial stream.
688
+ membersCsvForSegment: function (slug, csvOpts) {
689
+ _slug(slug);
690
+ csvOpts = csvOpts || {};
691
+ var batch = EXPORT_BATCH_SIZE;
692
+ var cursorVal = null;
693
+ var done = false;
694
+ var preludeEmitted = false;
695
+ var seg = null;
696
+
697
+ async function _resolveSegment() {
698
+ if (seg) return seg;
699
+ var row = await _readSegment(slug);
700
+ if (!row) {
701
+ throw new TypeError("customer-segments.membersCsvForSegment: unknown slug " + JSON.stringify(slug));
702
+ }
703
+ seg = row;
704
+ return seg;
705
+ }
706
+
707
+ return {
708
+ [Symbol.asyncIterator]: function () { return this; },
709
+ next: async function () {
710
+ if (done) return { value: undefined, done: true };
711
+ var s = await _resolveSegment();
712
+ if (!preludeEmitted) {
713
+ preludeEmitted = true;
714
+ // Leading comment row — RFC-4180-quoted so spreadsheets keep it
715
+ // intact; states why no email column exists. Then the header.
716
+ var prelude =
717
+ _csvRow(["# Segment members export — email addresses are omitted (stored as a one-way hash, never reconstructed)."]) +
718
+ _csvRow(MEMBER_EXPORT_COLUMNS);
719
+ // An archived segment has an empty cache by contract — the
720
+ // header still emits so the consumer gets a well-formed
721
+ // (empty) file rather than a 404 mid-stream.
722
+ return { value: prelude, done: false };
723
+ }
724
+ var sql, params;
725
+ if (cursorVal != null) {
726
+ sql = "SELECT csm.customer_id AS customer_id, csm.evaluated_at AS joined_segment_at, " +
727
+ " c.display_name AS display_name, " +
728
+ " (SELECT COUNT(*) FROM orders o WHERE o.customer_id = csm.customer_id AND o.status != 'cancelled') AS order_count " +
729
+ "FROM customer_segment_membership csm " +
730
+ "LEFT JOIN customers c ON c.id = csm.customer_id " +
731
+ "WHERE csm.segment_id = ?1 AND csm.customer_id > ?2 " +
732
+ "ORDER BY csm.customer_id ASC LIMIT ?3";
733
+ params = [s.id, cursorVal, batch];
734
+ } else {
735
+ sql = "SELECT csm.customer_id AS customer_id, csm.evaluated_at AS joined_segment_at, " +
736
+ " c.display_name AS display_name, " +
737
+ " (SELECT COUNT(*) FROM orders o WHERE o.customer_id = csm.customer_id AND o.status != 'cancelled') AS order_count " +
738
+ "FROM customer_segment_membership csm " +
739
+ "LEFT JOIN customers c ON c.id = csm.customer_id " +
740
+ "WHERE csm.segment_id = ?1 " +
741
+ "ORDER BY csm.customer_id ASC LIMIT ?2";
742
+ params = [s.id, batch];
743
+ }
744
+ var rows = (await query(sql, params)).rows;
745
+ if (rows.length === 0) { done = true; return { value: undefined, done: true }; }
746
+ var out = "";
747
+ for (var i = 0; i < rows.length; i += 1) {
748
+ var r = rows[i];
749
+ out += _csvRow([
750
+ r.customer_id,
751
+ r.display_name == null ? "" : r.display_name,
752
+ r.joined_segment_at == null ? "" : r.joined_segment_at,
753
+ r.order_count == null ? 0 : r.order_count,
754
+ ]);
755
+ }
756
+ cursorVal = rows[rows.length - 1].customer_id;
757
+ if (rows.length < batch) done = true;
758
+ return { value: out, done: false };
759
+ },
760
+ };
761
+ },
762
+
618
763
  recompute: async function (recomputeOpts) {
619
764
  recomputeOpts = recomputeOpts || {};
620
765
  var slugFilter = null;
@@ -811,4 +956,9 @@ module.exports = {
811
956
  ALLOWED_ORDER_STATUSES: ALLOWED_ORDER_STATUSES,
812
957
  DEFAULT_LIMIT: DEFAULT_LIMIT,
813
958
  MAX_LIMIT: MAX_LIMIT,
959
+ MEMBER_EXPORT_COLUMNS: MEMBER_EXPORT_COLUMNS,
960
+ // Exposed for the test suite to assert CSV cell semantics without a
961
+ // full DB round-trip.
962
+ _csvCell: _csvCell,
963
+ _neutralizeInjection: _neutralizeInjection,
814
964
  };
@@ -90,6 +90,14 @@ var HEALTH_PATH = "/_/health";
90
90
  // request-supplied address — without the tight
91
91
  // budget it is a victim-addressed mail cannon on
92
92
  // the loose global bucket alone.
93
+ // /cart/coupon -> apply + remove a typed discount code. POST /cart/coupon
94
+ // validates the code against the discount engine; on a
95
+ // miss it returns a UNIFORM error (no existence oracle),
96
+ // which makes the loose global bucket alone a code-guessing
97
+ // engine — a sprayer can grind the coupon namespace for a
98
+ // live code. The tight per-(IP+path) budget caps the
99
+ // guess rate (same guessable-secret rationale as the
100
+ // /gift-cards/balance lookup). Container-only.
93
101
  var TIGHT_PREFIXES = [
94
102
  "/account/login",
95
103
  "/account/register",
@@ -102,6 +110,7 @@ var TIGHT_PREFIXES = [
102
110
  "/survey/",
103
111
  "/orders/",
104
112
  "/stock-alert/",
113
+ "/cart/coupon",
105
114
  ];
106
115
 
107
116
  // Edge-served state-changing POST endpoints. These forms are rendered at
package/lib/storefront.js CHANGED
@@ -11306,7 +11306,12 @@ function mount(router, deps) {
11306
11306
  // address with no usable postal falls through to null here.
11307
11307
  var coAuth = _currentCustomerEnv(req);
11308
11308
  if (!coAuth) return null;
11309
- var dest = await _estimateDestination(req);
11309
+ // Reuse a destination the caller already resolved (the cart GET
11310
+ // resolves it once and threads it into both this and
11311
+ // _estimateCartTotals) so a signed-in cart render doesn't run the
11312
+ // address lookup twice. Falls back to resolving it here for callers
11313
+ // that don't pass one (the PDP).
11314
+ var dest = opts.dest || await _estimateDestination(req);
11310
11315
  if (!dest || !dest.from_saved || !dest.ship_to || !dest.ship_to.postal) return null;
11311
11316
  // Operator-configured origin — the primitive won't guess one. Resolved
11312
11317
  // at boot into `deps.delivery_estimate_origin` (a plain slug string) from
@@ -11395,9 +11400,13 @@ function mount(router, deps) {
11395
11400
  destination: null,
11396
11401
  };
11397
11402
  if (!deps.checkout || typeof deps.checkout.quote !== "function") return result;
11403
+ // `opts.ship_to` (a shopper-confirmed address from the checkout POST) wins;
11404
+ // else reuse a destination the caller already resolved (`opts.dest`, so a
11405
+ // signed-in cart render doesn't run the address lookup twice — once here
11406
+ // and once in _resolveDeliveryEstimate); else resolve it now.
11398
11407
  var dest = opts.ship_to
11399
11408
  ? { ship_to: opts.ship_to, from_saved: false }
11400
- : await _estimateDestination(req);
11409
+ : (opts.dest || await _estimateDestination(req));
11401
11410
  result.destination = dest;
11402
11411
  try {
11403
11412
  // quote() without a selected_shipping_id returns the tax row + ALL
@@ -12288,12 +12297,17 @@ function mount(router, deps) {
12288
12297
  catch (_e) { appliedCodes = []; }
12289
12298
  }
12290
12299
  var appliedCodeStrings = appliedCodes.map(function (r) { return r.code; });
12300
+ // Resolve the estimate destination ONCE for the render: both the totals
12301
+ // estimate and the "Get it by <date>" delivery estimate need it, and the
12302
+ // lookup (a saved-address read for a signed-in customer) is otherwise run
12303
+ // twice per cart GET. Thread the single result into both.
12304
+ var estimateDest = await _estimateDestination(req);
12291
12305
  // Real total before pay: compose the same tax + shipping primitives the
12292
12306
  // charge runs through (estimated against the shopper's saved/default
12293
12307
  // destination until they confirm an address at checkout). Falls back to
12294
12308
  // a subtotal-only breakdown — with tax/shipping labelled "calculated at
12295
12309
  // checkout" — when checkout isn't wired or no zone matches.
12296
- var totalsDetail = await _estimateCartTotals(req, c, lines, { codes: appliedCodeStrings });
12310
+ var totalsDetail = await _estimateCartTotals(req, c, lines, { codes: appliedCodeStrings, dest: estimateDest });
12297
12311
  var totals = totalsDetail.totals;
12298
12312
  // Truthful per-line stock state (out / low / ok) so the cart never
12299
12313
  // implies a sold-out line is buyable.
@@ -12340,7 +12354,7 @@ function mount(router, deps) {
12340
12354
  // cart estimate is the destination window, not a per-parcel weight quote);
12341
12355
  // the primitive falls back to its weight-agnostic transit rows. Drop-
12342
12356
  // silent → null, and the summary renders no estimate.
12343
- var cartEstimate = await _resolveDeliveryEstimate(req, {});
12357
+ var cartEstimate = await _resolveDeliveryEstimate(req, { dest: estimateDest });
12344
12358
  _send(res, 200, renderCart(Object.assign({
12345
12359
  lines: lines,
12346
12360
  totals: totals,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.71",
3
+ "version": "0.3.73",
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": {