@blamejs/blamejs-shop 0.4.25 → 0.4.27

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.
@@ -101,6 +101,7 @@ var INTERNAL_BRIDGE_PATHS = [
101
101
  "/_/campaign-send-tick",
102
102
  "/_/customer-portal-expire",
103
103
  "/_/stale-order-reap",
104
+ "/_/quote-expiry-tick",
104
105
  ];
105
106
 
106
107
  // Public well-known paths fetched by third-party verification crawlers
@@ -290,7 +291,25 @@ function clientKey(req) {
290
291
  * so both substrates stay header-consistent.
291
292
  */
292
293
  function securityHeadersOpts() {
293
- return { documentPolicy: false, referrerPolicy: "same-origin" };
294
+ // `trustProxy: true` is what lets the vendored HSTS header actually
295
+ // ship from the container. The vendored securityHeaders middleware
296
+ // emits Strict-Transport-Security ONLY when the request protocol
297
+ // resolves to https (RFC 6797 §7.2: HSTS over plain HTTP is ignored by
298
+ // UAs, so the middleware suppresses it on non-TLS requests). Behind the
299
+ // Cloudflare Worker the container socket is plain http and the real
300
+ // scheme rides in the Worker-set `x-forwarded-proto: https` — without
301
+ // trustProxy the middleware reads `http`, decides the request isn't
302
+ // TLS, and drops HSTS on EVERY container-served response. The edge
303
+ // Worker sets its own HSTS on edge-rendered pages, but a direct-to-
304
+ // container request (edge render off, or an internal hop that returns
305
+ // HTML) then carries no HSTS at all. Opting into the forwarded-proto
306
+ // header — the same stance the csrf gate already takes (mountRouteGuards
307
+ // passes trustProxy:true) and the storefront session-cookie path takes
308
+ // (`_secureForReq`) — restores the header on container responses. HSTS
309
+ // stays OWNED by the vendored middleware (we set no header ourselves),
310
+ // so there is no double-set: on an edge-rendered page only the Worker's
311
+ // header is present; on a container-served page only this one is.
312
+ return { documentPolicy: false, referrerPolicy: "same-origin", trustProxy: true };
294
313
  }
295
314
 
296
315
  // ---- route-scoped CSP (payment processors + CAPTCHA providers) ----------
@@ -52,6 +52,11 @@
52
52
  * - expiringWithin({ customer_id, days })
53
53
  * - bulkBalance({ customer_ids })
54
54
  * - cleanupExpired({ now })
55
+ * - exportForCustomer(customer_id) — DSR export reader: the
56
+ * subject's balance + full ledger history.
57
+ * - eraseForCustomer(customer_id, { dry_run? }) — DSR erasure:
58
+ * RETAINS (financial ledger, legal-obligation basis). Returns
59
+ * the `{ table, deleted: 0, note }` reader contract.
55
60
  *
56
61
  * Storage:
57
62
  * - store_credit_ledger (migration 0094).
@@ -491,6 +496,42 @@ function create(opts) {
491
496
  return out;
492
497
  },
493
498
 
499
+ // ---- exportForCustomer / eraseForCustomer (DSR) -----------------
500
+ //
501
+ // Subject-access-request hooks consumed by complianceExport. The
502
+ // ledger keys on `customer_id`.
503
+ //
504
+ // exportForCustomer(customer_id) — the subject's current balance +
505
+ // their full ledger history (the financial record of every credit /
506
+ // debit / expire against their wallet). Pure read; the history is
507
+ // capped at the same 500-row ceiling `history()` enforces.
508
+ exportForCustomer: async function (customerId) {
509
+ var cid = _uuid(customerId, "customer_id");
510
+ var bal = await _currentBalance(cid);
511
+ var r = await query(
512
+ "SELECT id, customer_id, kind, amount_minor, source, source_ref, order_id, " +
513
+ "balance_after_minor, expires_at, occurred_at FROM store_credit_ledger " +
514
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 500",
515
+ [cid],
516
+ );
517
+ return { balance_minor: bal, history: r.rows };
518
+ },
519
+
520
+ // eraseForCustomer(customer_id, { dry_run }) — store credit is a
521
+ // financial ledger: an audited record of money owed to and spent by
522
+ // the customer, retained under the controller's legal-obligation /
523
+ // accounting basis (the same posture as the loyalty ledger and gift
524
+ // cards in the DSR reader map). Erasure RETAINS — deleting the rows
525
+ // would destroy the proof of a balance the customer may still be
526
+ // entitled to and the accounting trail a tax / chargeback audit
527
+ // needs. Returns `{ table, deleted: 0, note }`; dry_run reports the
528
+ // same retain decision (0 rows would be removed). The customer-
529
+ // identity scrub rides the anonymized customers row.
530
+ eraseForCustomer: async function (customerId, _opts) {
531
+ _uuid(customerId, "customer_id");
532
+ return { table: "store_credit_ledger", deleted: 0, note: "retained-financial-ledger" };
533
+ },
534
+
494
535
  cleanupExpired: async function (input) {
495
536
  input = input || {};
496
537
  var now = _epochMs(input.now, "now");
package/lib/storefront.js CHANGED
@@ -10930,7 +10930,13 @@ function renderQuotePage(opts) {
10930
10930
  var lineRows = (q.lines || []).map(function (l) {
10931
10931
  var unit = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor, l.currency || currency);
10932
10932
  var total = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor * l.quantity, l.currency || currency);
10933
- return "<tr><td>" + esc(l.sku) + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
10933
+ // Per-line note free text captured on the RFQ line, rendered under
10934
+ // the item so the priced offer reads against what was asked for.
10935
+ // Escaped like every other free-text field on this page.
10936
+ var note = l.notes
10937
+ ? "<div class=\"quote-line__note\">" + esc(l.notes) + "</div>"
10938
+ : "";
10939
+ return "<tr><td>" + esc(l.sku) + note + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
10934
10940
  "<td class=\"num\">" + esc(unit) + "</td><td class=\"num\">" + esc(total) + "</td></tr>";
10935
10941
  }).join("");
10936
10942
  var rowsHtml =
@@ -11019,11 +11025,17 @@ function renderQuoteList(opts) {
11019
11025
  var currency = q.currency || "USD";
11020
11026
  var total = q.total_minor == null ? "Awaiting price" : pricing.format(q.total_minor, currency);
11021
11027
  var statusLabel = q.status === "responded" ? "ready to review" : q.status;
11028
+ // Make the acceptance window visible from the list — a plain
11029
+ // server-rendered date on quotes that are still open to accept.
11030
+ var valid = (q.status === "responded" && q.valid_until)
11031
+ ? "<span class=\"quote-list__valid\">Valid until " + esc(new Date(Number(q.valid_until)).toUTCString()) + "</span>"
11032
+ : "";
11022
11033
  return "<li class=\"quote-list__item\">" +
11023
11034
  "<a href=\"/account/quotes/" + esc(q.id) + "\">" +
11024
11035
  "<span class=\"quote-list__id\">Quote " + esc(String(q.id).slice(0, 8)) + "</span>" +
11025
11036
  "<span class=\"quote-list__status\">" + esc(statusLabel) + "</span>" +
11026
11037
  "<span class=\"quote-list__total\">" + esc(total) + "</span>" +
11038
+ valid +
11027
11039
  "</a></li>";
11028
11040
  }).join("");
11029
11041
  body =
@@ -15873,7 +15885,8 @@ function mount(router, deps) {
15873
15885
  try {
15874
15886
  var portalCust = await deps.customers.get(rv.customer_id);
15875
15887
  if (portalCust && portalCust.email_hash) {
15876
- await deps.order.linkGuestOrdersByEmailHash(rv.customer_id, portalCust.email_hash);
15888
+ await deps.order.linkGuestOrdersByEmailHash(
15889
+ rv.customer_id, portalCust.email_hash, { linked_via: "magic-link" });
15877
15890
  }
15878
15891
  } catch (_eLink) { /* drop-silent — sign-in itself succeeds */ }
15879
15892
  }
@@ -16124,6 +16137,17 @@ function mount(router, deps) {
16124
16137
  if (anonCart) await deps.cart.setCustomer(anonCart.id, customer.id);
16125
16138
  } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
16126
16139
  }
16140
+ // Guest-order reconciliation is deliberately NOT done here. A passkey
16141
+ // assertion proves control of the AUTHENTICATOR, not of the account's
16142
+ // email — and a passkey-registered account's email_hash was never
16143
+ // verified (the register path stores whatever address was typed). The
16144
+ // attach is gated strictly on PROVEN email ownership: an OIDC sign-in
16145
+ // the provider verified, or a magic-link the buyer clicked. Linking on
16146
+ // the account's unverified email_hash here would let an attacker who
16147
+ // registered a passkey under a victim's address inherit the victim's
16148
+ // guest orders — the takeover class the customers model guards against.
16149
+ // A passkey-account holder who wants their guest orders attached signs
16150
+ // in once via the magic-link, which proves the email and reconciles.
16127
16151
  _clearChallengeCookie(res);
16128
16152
  _setAuthCookie(req, res, {
16129
16153
  customer_id: customer.id,
@@ -16949,7 +16973,8 @@ function mount(router, deps) {
16949
16973
  if (claims.email_verified === true && claims.email && deps.order &&
16950
16974
  typeof deps.order.linkGuestOrdersByEmailHash === "function") {
16951
16975
  try {
16952
- await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
16976
+ await deps.order.linkGuestOrdersByEmailHash(
16977
+ rv.customer.id, deps.customers.hashEmail(claims.email), { linked_via: "verified-email" });
16953
16978
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
16954
16979
  }
16955
16980
  // Attribute a referral ONLY on a genuinely new account
@@ -17063,7 +17088,8 @@ function mount(router, deps) {
17063
17088
  if (emailVerified && claims.email && deps.order &&
17064
17089
  typeof deps.order.linkGuestOrdersByEmailHash === "function") {
17065
17090
  try {
17066
- await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
17091
+ await deps.order.linkGuestOrdersByEmailHash(
17092
+ rv.customer.id, deps.customers.hashEmail(claims.email), { linked_via: "verified-email" });
17067
17093
  } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
17068
17094
  }
17069
17095
  // Attribute a referral ONLY on a genuinely new account (rv.created)
@@ -102,6 +102,13 @@
102
102
  * - metricsForCategory({ category, from, to })
103
103
  * - archiveSuggestion(id)
104
104
  * - flagAsSpam({ suggestion_id, flagged })
105
+ * - exportForCustomer({ customer_id?, email_hash? }) — DSR export
106
+ * reader: the subject's own suggestion rows (matched on
107
+ * customer_id OR the suggestion-box-namespace email hash).
108
+ * - eraseForCustomer({ customer_id?, email_hash?, dry_run? }) —
109
+ * DSR erasure: anonymizes the subject's rows in place (both
110
+ * identity keys -> NULL), keeping the de-identified roadmap
111
+ * signal. Returns the `{ table, deleted }` reader contract.
105
112
  *
106
113
  * Storage: `migrations-d1/0181_suggestion_box.sql` —
107
114
  * `suggestions` + `suggestion_votes`.
@@ -850,6 +857,121 @@ function create(opts) {
850
857
  return _decode(await _getRaw(sid));
851
858
  }
852
859
 
860
+ // ---- exportForCustomer / eraseForCustomer (DSR) -----------------------
861
+ //
862
+ // Subject-access-request hooks consumed by complianceExport. A
863
+ // suggestion row is keyed to a person by EITHER `customer_id` (an
864
+ // authenticated submission) OR `customer_email_hash` (a storefront
865
+ // visitor who supplied a confirmed email but wasn't signed in). The
866
+ // email is hashed under the `suggestion-box-email` namespace
867
+ // (EMAIL_NAMESPACE), distinct from the customers table's own
868
+ // `customer-email` namespace, and the raw email is never stored —
869
+ // so an email-only row can be matched to a customer ONLY when the
870
+ // caller can supply that same suggestion-box namespace hash.
871
+ //
872
+ // The DSR composition root resolves a customer by `customer_id`
873
+ // (no raw email is held anywhere in the system to re-hash), so it
874
+ // passes `customer_id` and — when the requesting customer's email
875
+ // is known in-session — the derived `email_hash`. Both keys are
876
+ // matched with OR so a customer's authenticated AND email-only
877
+ // submissions are covered. A caller WITH the raw email derives the
878
+ // hash via the exported EMAIL_NAMESPACE + b.crypto.namespaceHash.
879
+
880
+ // Normalize the DSR selector: at least one of customer_id /
881
+ // email_hash must be present. Returns { custId, emailHash } with
882
+ // validated/null values. Defensive request-shape reader at the
883
+ // primitive boundary — bad UUID shape throws (operator catches the
884
+ // typo); a null/absent key is simply not used in the predicate.
885
+ function _dsrSelector(input, method) {
886
+ if (!input || typeof input !== "object") {
887
+ throw new TypeError("suggestionBox." + method + ": input object required");
888
+ }
889
+ var custId = _customerIdOpt(input.customer_id);
890
+ var emailHash = null;
891
+ if (input.email_hash != null) {
892
+ if (typeof input.email_hash !== "string" || !input.email_hash.length || input.email_hash.length > 256) {
893
+ throw new TypeError("suggestionBox." + method + ": email_hash must be a non-empty string <= 256 chars when provided");
894
+ }
895
+ if (CONTROL_BYTE_STRICT_RE.test(input.email_hash) || ZERO_WIDTH_RE.test(input.email_hash)) {
896
+ throw new TypeError("suggestionBox." + method + ": email_hash contains control / zero-width bytes");
897
+ }
898
+ emailHash = input.email_hash;
899
+ }
900
+ if (custId == null && emailHash == null) {
901
+ throw new TypeError("suggestionBox." + method + ": at least one of customer_id / email_hash is required");
902
+ }
903
+ return { custId: custId, emailHash: emailHash };
904
+ }
905
+
906
+ // Build the `customer_id = ? OR customer_email_hash = ?` predicate
907
+ // from whichever selector keys are present, returning { clause,
908
+ // params }. Shared by export + erase so the two never diverge on
909
+ // which rows belong to the subject.
910
+ function _dsrWhere(sel) {
911
+ var ors = [];
912
+ var params = [];
913
+ var idx = 1;
914
+ if (sel.custId != null) { ors.push("customer_id = ?" + idx); params.push(sel.custId); idx += 1; }
915
+ if (sel.emailHash != null) { ors.push("customer_email_hash = ?" + idx); params.push(sel.emailHash); idx += 1; }
916
+ return { clause: "(" + ors.join(" OR ") + ")", params: params };
917
+ }
918
+
919
+ // exportForCustomer({ customer_id?, email_hash? }) — returns the
920
+ // subject's own suggestion rows (decoded). Read-only; capped at
921
+ // MAX_LIST_LIMIT so a single customer's history can't unbounded-
922
+ // stream the export. Spam-flagged / archived rows ARE included —
923
+ // the subject is entitled to a copy of everything held about them,
924
+ // including operator-only tombstoned rows.
925
+ async function exportForCustomer(input) {
926
+ var sel = _dsrSelector(input, "exportForCustomer");
927
+ var w = _dsrWhere(sel);
928
+ var r = await query(
929
+ "SELECT * FROM suggestions WHERE " + w.clause +
930
+ " ORDER BY created_at DESC, id DESC LIMIT ?" + (w.params.length + 1),
931
+ w.params.concat([MAX_LIST_LIMIT]),
932
+ );
933
+ var out = [];
934
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
935
+ return out;
936
+ }
937
+
938
+ // eraseForCustomer({ customer_id?, email_hash?, dry_run? }) —
939
+ // GDPR Art. 17 erasure. A suggestion (especially a `complaint`)
940
+ // carries the customer's own free-text words + a hashed email, so
941
+ // it is personal data with no retention basis once the subject
942
+ // asks for erasure. Rather than DELETE the row (which would orphan
943
+ // any votes / canonical-link pointers and lose the operator's
944
+ // roadmap signal), this ANONYMIZES it in place: severs both
945
+ // identity keys (customer_id + customer_email_hash → NULL) so the
946
+ // row can no longer be traced to the person, while the
947
+ // already-published title/body/votes stay as de-identified
948
+ // roadmap input. dry_run counts the rows it WOULD anonymize
949
+ // without mutating. Returns the complianceExport reader contract
950
+ // shape `{ table, deleted }`.
951
+ async function eraseForCustomer(input) {
952
+ var dryRun = !!(input && input.dry_run);
953
+ var sel = _dsrSelector(input, "eraseForCustomer");
954
+ var w = _dsrWhere(sel);
955
+ // Only rows still carrying an identity key need anonymizing —
956
+ // a re-run finds none (idempotent).
957
+ var countRow = (await query(
958
+ "SELECT COUNT(*) AS n FROM suggestions WHERE " + w.clause +
959
+ " AND (customer_id IS NOT NULL OR customer_email_hash IS NOT NULL)",
960
+ w.params,
961
+ )).rows[0];
962
+ var n = countRow ? Number(countRow.n) : 0;
963
+ if (dryRun) return { table: "suggestions", deleted: n };
964
+ if (n === 0) return { table: "suggestions", deleted: 0 };
965
+ var ts = _now();
966
+ var res = await query(
967
+ "UPDATE suggestions SET customer_id = NULL, customer_email_hash = NULL, updated_at = ?" +
968
+ (w.params.length + 1) + " WHERE " + w.clause +
969
+ " AND (customer_id IS NOT NULL OR customer_email_hash IS NOT NULL)",
970
+ w.params.concat([ts]),
971
+ );
972
+ return { table: "suggestions", deleted: Number((res && res.rowCount) || 0) };
973
+ }
974
+
853
975
  // ---- flagAsSpam -------------------------------------------------------
854
976
  //
855
977
  // Operator-only. Sets spam_flagged = 1 (or back to 0 if the
@@ -897,6 +1019,8 @@ function create(opts) {
897
1019
  metricsForCategory: metricsForCategory,
898
1020
  archiveSuggestion: archiveSuggestion,
899
1021
  flagAsSpam: flagAsSpam,
1022
+ exportForCustomer: exportForCustomer,
1023
+ eraseForCustomer: eraseForCustomer,
900
1024
  };
901
1025
  }
902
1026
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
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": {