@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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +33 -1
- package/lib/admin.js +302 -15
- package/lib/asset-manifest.json +3 -3
- package/lib/compliance-export.js +18 -7
- package/lib/order.js +96 -12
- package/lib/quotes.js +216 -82
- package/lib/save-for-later.js +46 -0
- package/lib/security-middleware.js +20 -1
- package/lib/store-credit.js +41 -0
- package/lib/storefront.js +30 -4
- package/lib/suggestion-box.js +124 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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) ----------
|
package/lib/store-credit.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
package/lib/suggestion-box.js
CHANGED
|
@@ -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