@blamejs/blamejs-shop 0.4.29 → 0.4.30
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 +2 -0
- package/SECURITY.md +16 -7
- package/lib/asset-manifest.json +1 -1
- package/lib/compliance-export.js +32 -7
- package/lib/order.js +46 -0
- package/lib/stock-alerts.js +110 -0
- package/lib/storefront.js +14 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.30 (2026-06-11) — **Privacy exports and erasures now cover every customer-keyed table — including the guest-order claim audit, stock alerts' plaintext email, quotes, ratings, Q&A, operator notes, gift cards, and referrals.** A privacy-completeness release. A subject-access export now walks eight more customer-keyed domains, and erasure handles each with a stated basis: the guest-order claim audit's email hash — a verbatim copy of the lookup key the account erasure deliberately severs, which previously survived in full — is tombstoned under its own derivation while the order linkage stays under the audit basis; a stock-alert subscription's plaintext email is deleted outright; quotes, ratings, and Q&A keep their de-identified business records with the customer's free text and identity keys cleared; operator notes are deleted; gift cards and referral records stay under their accounting basis with the identity links severed. A stock-alert subscription made while signed in now links to the account, so it follows the customer through export and erasure rather than floating free. Every new domain reports in the export's completeness manifest and in the erasure's per-domain results — an unwired reader shows as absent, never silently dropped. No migration to apply. **Changed:** *Six more domains in the export, each with stated erasure semantics* — Quote requests and their negotiation messages, fulfillment ratings, product Q&A, operator customer notes, gift-card issue records, and referral activity (in both directions — as referrer and as referred) now stream into the subject-access export. Erasure treats each by its nature: the customer's quote message and Q&A identity are cleared in place with the de-identified business record retained, ratings and operator notes are deleted, and gift cards and referral accounting stay under their legal-obligation basis with the identity links severed and referred-email hashes tombstoned. Each domain reports its effect in the deletion result, and a deletion preview (dry run) touches nothing — verified per domain. **Fixed:** *Guest-order claim records join the export and erasure* — When a guest order attaches to an account on verified sign-in, the attachment is recorded with the buyer's email hash as the linking key. Those records are now part of the subject-access export, and an erasure tombstones the email hash under a derivation distinct from the account-level tombstone — so the two can never be cross-correlated — while the order linkage itself is retained under the same audit basis orders use. Previously the records were absent from the export, untouched by erasure, and invisible in the completeness manifest, and the surviving hash defeated the account erasure's deliberate severing of that key. · *Stock-alert subscriptions stop outliving an erasure* — A back-in-stock subscription stores the subscriber's email in plaintext — it has to send mail to it — but those rows survived an account erasure and never appeared in the export. The export now includes the customer's subscriptions (minus the bearer token hashes), erasure deletes the rows outright — freeing the address to subscribe again — and a subscription made while signed in is linked to the account so it follows the customer. Anonymous subscriptions keep their existing bounded lifetime and stay reachable by their unsubscribe token.
|
|
12
|
+
|
|
11
13
|
- v0.4.29 (2026-06-11) — **A gift card can no longer pay for two orders at once — credits debit before any charge — and store-credit wallets, capped discounts, and the gift-card audit chain all hold under concurrency.** A money-integrity release closing five concurrency windows, each reproduced before fixing. The serious one: gift-card and loyalty credits were debited after the order existed, with failures captured for reconciliation — so two simultaneous checkouts presenting the same gift card both produced paid orders while the card was only debited once. Credits now debit before any charge: the database balance gate decides the race, the loser gets a clean re-quote, and a checkout that fails after the debit but before the order exists reverses the debit automatically. Store-credit wallets stop computing balances from a stale read — concurrent debits can no longer overdraw, and two grants landing in the same millisecond both count. Capped automatic discounts are reserved atomically before charging, so a last-redemption race refuses one buyer with a clear message instead of granting both. Every gift-card ledger entry — debits included — now participates in the per-card tamper-evidence hash chain, a uniqueness fence keeps concurrent writes from forking it, and a new verifyChain call recomputes a card's chain on demand. The payment idempotency cache absorbs same-key races instead of failing one of them. Upgrade applies two D1 migrations. **Fixed:** *Store-credit wallets hold under concurrent writes* — Wallet writes computed the new balance from a separately-read snapshot, so two concurrent debits could both fulfill against one balance — overdrawing the wallet — and two grants landing in the same millisecond could tie on their timestamp and silently drop one. Every wallet write now computes the live balance and a strictly-monotonic per-customer timestamp inside a single guarded insert: a debit that loses the race is refused as insufficient, both same-instant grants land and sum, and the scheduled expiry sweep keeps its degrade-gracefully cap. · *Capped automatic discounts are reserved before charging* — A rule's redemption caps were only read at quote time and counted after the order existed, so a single-use discount applied to every order that raced the last redemption. The applied rules are now claimed atomically before any charge — total cap and per-customer cap both enforced inside single guarded statements — and a refused claim fails the checkout closed with a clear message and re-quote, never a silently different price. A checkout that fails before its order exists releases its reservations, recording a redemption is idempotent per order, and a retried checkout reuses its own claim instead of double-reserving. · *Same-key payment calls absorb their race* — Two concurrent calls carrying the same idempotency key could both miss the replay cache and collide on its primary key, failing one of them with a constraint error. The cache claim is now conflict-aware: one call stores its response, the other defers to it and replays — and a same-key call carrying a different request body is still refused as a collision, racing or not. **Security:** *Gift-card and loyalty credits debit before any charge* — A checkout's gift-card and loyalty debits are now the first money movement, ahead of the payment intent and the order row, on the card-payment and PayPal paths alike. The database balance predicate is the cross-checkout double-spend gate: two carts presenting the same card race it directly, exactly one wins, and the loser's checkout rolls back cleanly — stock holds released, cart reusable, a clear message and re-quote, nothing charged. A checkout that dies between the debit and order creation reverses the debit (claim-guarded, exactly once). Once the order exists the debit is attached to it, so refunds and cancellations keep reversing credit proportionally exactly as before. · *Every gift-card ledger entry is chained, and the chain can't fork* — Debit rows — previously written outside the hash chain by the atomic overdraft guard — now carry the same parent and row hashes as credits and expirations, with the overdraft gate still enforced inside the insert. A per-card uniqueness fence (one child per chain tip) makes concurrent writes serialize instead of forking the chain or basing a balance on a stale snapshot; a writer that loses the race re-reads the tip and retries. A new verifyChain call recomputes a card's chain end to end and reports the first divergence, tolerating rows that predate the chain columns as a counted, unverifiable prefix.
|
|
12
14
|
|
|
13
15
|
- v0.4.28 (2026-06-11) — **Console refunds reach PayPal, refund webhooks apply their stated amount instead of reversing everything, and gift cards and loyalty points now ride the PayPal button.** A payment-lifecycle release for stores taking PayPal. Refunding a PayPal-paid order from the console — full, partial, or through the returns flow — now dials PayPal; previously every console refund dialed the card processor with a PayPal id and failed, leaving the PayPal dashboard as the only way to refund. Refund webhooks from both processors now apply the amount they state: a partial refund issued from the processor's dashboard reverses gift-card and loyalty credit proportionally, where before it triggered the full terminal reversal and could hand a customer the entire credited value back for a five-dollar refund. PayPal webhook deliveries are now claimed in a replay store after signature verification, the verification call runs on its own circuit breaker behind a per-IP budget so a forged-delivery flood can't fast-fail live checkouts, and a buyer paying with the PayPal button can now apply a gift card or spend loyalty points like any other checkout. A deployment with PayPal credentials but no webhook id gets a boot warning naming the missing variable. Upgrade applies two D1 migrations. **Fixed:** *Console refunds route by the order's payment provider* — Orders persist which processor took the payment, and every refund surface — the order console's full and partial refund, the returns console's provider refund, and the refund-automation library — routes to that processor. PayPal refunds dial the capture (recovered from the order record, the payment transition's metadata, or the PayPal API, in that order), and the operator's idempotency key flows through as the PayPal request id so a retried partial refund deduplicates while distinct partial refunds execute distinctly. Orders that predate the provider column fall back to the payment-id shape. The refund button now reflects provider reality: it offers a refund only when the processor that took the payment is configured, and refuses with a specific reason — provider not configured, no capture on record — instead of a generic failure. · *Refund webhooks apply their stated amount* — A refund event arriving from the processor now reads the refunded amount instead of unconditionally driving the order to the terminal refunded state. A partial refund reverses gift-card and loyalty credit proportionally through the same accounting the console's partial refund uses; only a balance-clearing refund transitions the order to refunded. Both processors are covered: PayPal events carry a per-refund amount and deduplicate on the refund id, card-processor events carry a cumulative total and apply the delta against the local ledger. An event with a missing or unparseable amount is refused so the processor redelivers it — the handler never guesses a full refund. · *Gift cards and loyalty points apply to PayPal button payments* — The PayPal button now sends the gift-card code and loyalty-points fields from the pay form, matching card checkout. When a gift card covers the whole total, the page completes the order and redirects without opening the PayPal popup — the previous behavior surfaced a payment error to the buyer after the order had already been created, paid, and the card debited. **Security:** *PayPal webhook deliveries are claimed once and verified in isolation* — Each verified PayPal event id is claimed in a replay store before any state transition — matching the card-processor webhook discipline — so a replayed or re-delivered event is absorbed exactly once across the redelivery window. The signature-verification call to PayPal runs on its own circuit breaker, and the webhook path carries a per-IP request budget, so a flood of forged deliveries can neither trip the breaker that live checkout dials ride nor crowd out legitimate redeliveries. · *Boot warning when the PayPal webhook id is missing* — A deployment with PayPal client credentials but no PAYPAL_WEBHOOK_ID logs a warning at boot naming the variable. Verification itself remains mandatory and fails closed — without the id every webhook delivery is refused, which keeps forged events out but also means dashboard-issued refunds never mirror locally and PayPal will eventually disable the webhook endpoint; the warning makes that state visible instead of silent.
|
package/SECURITY.md
CHANGED
|
@@ -325,20 +325,29 @@ node -e "
|
|
|
325
325
|
by the customer — identity, orders, subscriptions, addresses, saved
|
|
326
326
|
payment-method metadata, support tickets, loyalty, reviews, the
|
|
327
327
|
consent ledger, wishlist, surveys, recently-viewed, suggestion-box
|
|
328
|
-
submissions, the save-for-later list,
|
|
328
|
+
submissions, the save-for-later list, the store-credit ledger, the
|
|
329
|
+
guest-order claim audit trail, back-in-stock alerts, quotes, order
|
|
330
|
+
ratings, product Q&A questions, operator notes about the customer,
|
|
331
|
+
gift cards issued to them, and referral activity in both directions —
|
|
329
332
|
so the bundle is the full record, not just the order/identity core.
|
|
330
333
|
The bundle carries a completeness manifest: every in-scope domain is
|
|
331
334
|
marked exported, empty, or absent, so a domain whose reader isn't
|
|
332
335
|
wired is visible rather than silently dropped. Erasure deletes the
|
|
333
336
|
pure-personalization domains (wishlist, recently-viewed, save-for-
|
|
334
|
-
later
|
|
335
|
-
|
|
337
|
+
later, stock alerts — including the alert's plaintext address — order
|
|
338
|
+
ratings, and operator customer notes), anonymizes in place the rows
|
|
339
|
+
whose de-identified content stays useful (suggestion-box submissions
|
|
340
|
+
and product Q&A questions: identity keys cleared, text retained),
|
|
341
|
+
clears the customer-authored message on retained quotes, revokes
|
|
336
342
|
every sign-in path and anonymizes the customer profile, and RETAINS
|
|
337
343
|
the records a controller keeps under a legal-obligation / accounting
|
|
338
|
-
basis (orders, loyalty ledger, store-credit ledger, consent
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
the
|
|
344
|
+
basis (orders, loyalty ledger, store-credit ledger, consent evidence,
|
|
345
|
+
published reviews, gift cards and referral reward accounting with
|
|
346
|
+
their identity keys severed, and the guest-order claim audit rows
|
|
347
|
+
with the recorded email hash replaced by a non-reversible tombstone)
|
|
348
|
+
— each with a stated basis in the per-domain result. Preview a
|
|
349
|
+
deletion with the dry-run flag to see the blast radius before the
|
|
350
|
+
irreversible call.
|
|
342
351
|
- **HSTS on every TLS response, edge and container.** Both substrates
|
|
343
352
|
send `Strict-Transport-Security: max-age=63072000; includeSubDomains;
|
|
344
353
|
preload` (two years, above the preload-list minimum). The container
|
package/lib/asset-manifest.json
CHANGED
package/lib/compliance-export.js
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* readers (customers, order, order-notes, subscriptions,
|
|
13
13
|
* addresses, payment-methods, support-tickets, loyalty, reviews,
|
|
14
14
|
* consent ledger, wishlist, surveys, recently-viewed, suggestion
|
|
15
|
-
* box, save-for-later, store credit
|
|
15
|
+
* box, save-for-later, store credit, guest-order reconciliations,
|
|
16
|
+
* stock alerts, quotes, order ratings, product Q&A, customer
|
|
17
|
+
* notes, gift cards, referrals) to assemble the bundle —
|
|
16
18
|
* every table that keys a row by the customer, so the export holds
|
|
17
19
|
* the whole record, not just the order/identity core.
|
|
18
20
|
* Delivery (email / signed URL / secure
|
|
@@ -85,7 +87,10 @@
|
|
|
85
87
|
* methods, support tickets, loyalty, reviews,
|
|
86
88
|
* consent ledger, wishlist, surveys,
|
|
87
89
|
* recently-viewed, suggestion box,
|
|
88
|
-
* save-for-later, store credit
|
|
90
|
+
* save-for-later, store credit, guest-order
|
|
91
|
+
* reconciliations, stock alerts, quotes,
|
|
92
|
+
* order ratings, product Q&A, customer
|
|
93
|
+
* notes, gift cards, referrals).
|
|
89
94
|
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
90
95
|
* identity / loyalty / subscriptions /
|
|
91
96
|
* addresses / payment methods / support
|
|
@@ -103,7 +108,9 @@
|
|
|
103
108
|
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
104
109
|
* paymentMethods, supportTickets, loyalty, reviews,
|
|
105
110
|
* consentLedger, wishlist, customerSurveys, recentlyViewed,
|
|
106
|
-
* suggestionBox, saveForLater, storeCredit,
|
|
111
|
+
* suggestionBox, saveForLater, storeCredit, stockAlerts,
|
|
112
|
+
* quotes, orderRatings, productQA, customerNotes,
|
|
113
|
+
* giftcards, referrals, orderExport
|
|
107
114
|
*/
|
|
108
115
|
|
|
109
116
|
var b = require("./vendor/blamejs");
|
|
@@ -195,6 +202,14 @@ var SCOPE_SECTIONS = Object.freeze({
|
|
|
195
202
|
// credit wallet ledger — each keys a row by the customer, so a
|
|
196
203
|
// subject-access export holds them too.
|
|
197
204
|
"suggestionBox", "saveForLater", "storeCredit",
|
|
205
|
+
// The remaining customer-keyed domains: the guest-order
|
|
206
|
+
// reconciliation audit trail, back-in-stock alert subscriptions
|
|
207
|
+
// (which hold a plaintext email), RFQ quotes, post-fulfillment
|
|
208
|
+
// ratings, product Q&A questions, operator CRM notes about the
|
|
209
|
+
// customer, gift cards issued to them, and referral activity in
|
|
210
|
+
// both directions (as referrer and as referred friend).
|
|
211
|
+
"guestOrderReconciliations", "stockAlerts", "quotes", "orderRatings",
|
|
212
|
+
"productQa", "customerNotes", "giftcards", "referrals",
|
|
198
213
|
]),
|
|
199
214
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
200
215
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -371,6 +386,14 @@ function create(opts) {
|
|
|
371
386
|
suggestionBox: opts.suggestionBox || null,
|
|
372
387
|
saveForLater: opts.saveForLater || null,
|
|
373
388
|
storeCredit: opts.storeCredit || null,
|
|
389
|
+
guestOrderReconciliations: opts.guestOrderReconciliations || null,
|
|
390
|
+
stockAlerts: opts.stockAlerts || null,
|
|
391
|
+
quotes: opts.quotes || null,
|
|
392
|
+
orderRatings: opts.orderRatings || null,
|
|
393
|
+
productQa: opts.productQa || null,
|
|
394
|
+
customerNotes: opts.customerNotes || null,
|
|
395
|
+
giftcards: opts.giftcards || null,
|
|
396
|
+
referrals: opts.referrals || null,
|
|
374
397
|
};
|
|
375
398
|
|
|
376
399
|
// ---- requestExport -------------------------------------------------
|
|
@@ -619,10 +642,12 @@ function create(opts) {
|
|
|
619
642
|
}
|
|
620
643
|
|
|
621
644
|
var domainOrder = [
|
|
622
|
-
"recentlyViewed", "wishlist", "saveForLater", "
|
|
623
|
-
"surveys", "reviews", "
|
|
624
|
-
"
|
|
625
|
-
"
|
|
645
|
+
"recentlyViewed", "wishlist", "saveForLater", "stockAlerts",
|
|
646
|
+
"suggestionBox", "surveys", "reviews", "orderRatings", "productQa",
|
|
647
|
+
"quotes", "customerNotes", "consentLedger",
|
|
648
|
+
"supportTickets", "orderNotes", "order", "guestOrderReconciliations",
|
|
649
|
+
"subscriptions", "paymentMethods", "loyalty", "storeCredit",
|
|
650
|
+
"giftcards", "referrals", "addresses", "customers",
|
|
626
651
|
];
|
|
627
652
|
var perDomain = [];
|
|
628
653
|
var domainsAbsent = [];
|
package/lib/order.js
CHANGED
|
@@ -1219,6 +1219,52 @@ function create(opts) {
|
|
|
1219
1219
|
}
|
|
1220
1220
|
},
|
|
1221
1221
|
|
|
1222
|
+
// Erasure scrub for the reconciliation audit trail. The attach linkage
|
|
1223
|
+
// (order_id / customer_id / linked_via / occurred_at) is RETAINED under
|
|
1224
|
+
// the same audit basis the orders themselves carry — a disputed link
|
|
1225
|
+
// must stay traceable after the account is gone. But the recorded
|
|
1226
|
+
// email_hash is a verbatim copy of the live customer-email lookup key,
|
|
1227
|
+
// and account erasure deliberately severs that key on the customers
|
|
1228
|
+
// row, so a surviving copy here would defeat the severance for any
|
|
1229
|
+
// customer who ever claimed a guest order. This rewrites it to a
|
|
1230
|
+
// per-customer, non-reversible tombstone under its OWN hash namespace
|
|
1231
|
+
// ("guest-recon-erased-email" — deliberately distinct from the
|
|
1232
|
+
// customers row's "customer-erased-email" label, so the two tombstones
|
|
1233
|
+
// never share a digest and can't be correlated as the same
|
|
1234
|
+
// derivation). Only live (non-tombstoned) hashes rewrite, so a re-run
|
|
1235
|
+
// is a no-op.
|
|
1236
|
+
//
|
|
1237
|
+
// `dry_run: true` counts the rows a wet run WOULD rewrite without
|
|
1238
|
+
// mutating anything — the side-effect-free preview the deletion
|
|
1239
|
+
// pipeline's dry-run contract requires. Returns `{ table, deleted }`
|
|
1240
|
+
// where `deleted` is the rows tombstoned (the rows themselves stay).
|
|
1241
|
+
// Defensive: a schema without the audit table collapses to
|
|
1242
|
+
// `{ table, deleted: 0 }` (drop-silent, same posture as the sibling
|
|
1243
|
+
// reads) so a partial schema never fails the caller's erasure run.
|
|
1244
|
+
scrubReconciliationEmailHashForCustomer: async function (customerId, opts2) {
|
|
1245
|
+
_uuid(customerId, "customer id");
|
|
1246
|
+
var dryRun = !!(opts2 && opts2.dry_run);
|
|
1247
|
+
try {
|
|
1248
|
+
if (dryRun) {
|
|
1249
|
+
var c = (await query(
|
|
1250
|
+
"SELECT COUNT(*) AS n FROM guest_order_reconciliations " +
|
|
1251
|
+
"WHERE customer_id = ?1 AND email_hash NOT LIKE 'erased:%'",
|
|
1252
|
+
[customerId],
|
|
1253
|
+
)).rows[0];
|
|
1254
|
+
return { table: "guest_order_reconciliations", deleted: c ? Number(c.n) : 0 };
|
|
1255
|
+
}
|
|
1256
|
+
var tombstone = "erased:" + b.crypto.namespaceHash("guest-recon-erased-email", customerId);
|
|
1257
|
+
var r = await query(
|
|
1258
|
+
"UPDATE guest_order_reconciliations SET email_hash = ?1 " +
|
|
1259
|
+
"WHERE customer_id = ?2 AND email_hash NOT LIKE 'erased:%'",
|
|
1260
|
+
[tombstone, customerId],
|
|
1261
|
+
);
|
|
1262
|
+
return { table: "guest_order_reconciliations", deleted: Number((r && r.rowCount) || 0) };
|
|
1263
|
+
} catch (_e) {
|
|
1264
|
+
return { table: "guest_order_reconciliations", deleted: 0 };
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
|
|
1222
1268
|
// Has this customer purchased this product? True iff an order
|
|
1223
1269
|
// line for any variant of the product sits in an order owned by
|
|
1224
1270
|
// the customer whose status is a real purchase — anything except
|
package/lib/stock-alerts.js
CHANGED
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
* prior row's terminal state is cleared from the unique-index slot
|
|
34
34
|
* first.
|
|
35
35
|
*
|
|
36
|
+
* Privacy requests. `exportForCustomer({ customer_id?, email_hash? })`
|
|
37
|
+
* returns the subject's subscription rows (the plaintext address
|
|
38
|
+
* included — it is the subject's own data; token hashes excluded)
|
|
39
|
+
* and `eraseForCustomer({ customer_id?, email_hash?, dry_run? })`
|
|
40
|
+
* hard-deletes them — a stock alert is convenience data with no
|
|
41
|
+
* retention basis. Both match on customer_id OR the stock-alert-
|
|
42
|
+
* namespace email hash, so a caller holding either key covers the
|
|
43
|
+
* subject's rows.
|
|
44
|
+
*
|
|
36
45
|
* Composes:
|
|
37
46
|
* - `b.crypto.namespaceHash` — email hash + token hash.
|
|
38
47
|
* - `b.crypto.generateBytes` — 24 random bytes →
|
|
@@ -157,6 +166,43 @@ function _hashUnsubToken(plaintext) {
|
|
|
157
166
|
return b.crypto.namespaceHash(UNSUB_NAMESPACE, plaintext);
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
// Normalize the DSR selector for exportForCustomer / eraseForCustomer:
|
|
170
|
+
// at least one of customer_id / email_hash must be present. Bad UUID
|
|
171
|
+
// shape throws (operator catches the typo at the boundary); a null /
|
|
172
|
+
// absent key is simply not used in the predicate. `email_hash` is the
|
|
173
|
+
// stock-alert-namespace digest (hex from b.crypto.namespaceHash), so a
|
|
174
|
+
// generous shape gate (non-empty printable string) is all it needs.
|
|
175
|
+
function _dsrSelector(input, method) {
|
|
176
|
+
if (!input || typeof input !== "object") {
|
|
177
|
+
throw new TypeError("stockAlerts." + method + ": input object required");
|
|
178
|
+
}
|
|
179
|
+
var custId = _optUuid(input.customer_id, "customer_id");
|
|
180
|
+
var emailHash = null;
|
|
181
|
+
if (input.email_hash != null) {
|
|
182
|
+
if (typeof input.email_hash !== "string" || !input.email_hash.length ||
|
|
183
|
+
input.email_hash.length > 256 || /[\x00-\x1f\x7f]/.test(input.email_hash)) {
|
|
184
|
+
throw new TypeError("stockAlerts." + method + ": email_hash must be a non-empty printable string <= 256 chars when provided");
|
|
185
|
+
}
|
|
186
|
+
emailHash = input.email_hash;
|
|
187
|
+
}
|
|
188
|
+
if (custId == null && emailHash == null) {
|
|
189
|
+
throw new TypeError("stockAlerts." + method + ": at least one of customer_id / email_hash is required");
|
|
190
|
+
}
|
|
191
|
+
return { custId: custId, emailHash: emailHash };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build the `customer_id = ? OR email_hash = ?` predicate from whichever
|
|
195
|
+
// selector keys are present. Shared by export + erase so the two never
|
|
196
|
+
// diverge on which rows belong to the subject.
|
|
197
|
+
function _dsrWhere(sel) {
|
|
198
|
+
var ors = [];
|
|
199
|
+
var params = [];
|
|
200
|
+
var idx = 1;
|
|
201
|
+
if (sel.custId != null) { ors.push("customer_id = ?" + idx); params.push(sel.custId); idx += 1; }
|
|
202
|
+
if (sel.emailHash != null) { ors.push("email_hash = ?" + idx); params.push(sel.emailHash); idx += 1; }
|
|
203
|
+
return { clause: "(" + ors.join(" OR ") + ")", params: params };
|
|
204
|
+
}
|
|
205
|
+
|
|
160
206
|
// ---- factory ------------------------------------------------------------
|
|
161
207
|
|
|
162
208
|
function create(opts) {
|
|
@@ -235,6 +281,14 @@ function create(opts) {
|
|
|
235
281
|
if (existing) {
|
|
236
282
|
var stillLive = existing.notified_at == null && Number(existing.expires_at) > now;
|
|
237
283
|
if (stillLive) {
|
|
284
|
+
// A re-subscribe NEVER re-keys the existing row's customer_id.
|
|
285
|
+
// The email on this request is caller-supplied and unproven, so
|
|
286
|
+
// adopting a row first created by someone else would let a
|
|
287
|
+
// signed-in caller pull a stranger's subscription into their own
|
|
288
|
+
// account scope. A row stays owned by whoever the INSERT linked
|
|
289
|
+
// it to (or anonymous); anonymous rows are reachable for privacy
|
|
290
|
+
// requests via the email_hash key + bearer token, exactly as
|
|
291
|
+
// eraseForCustomer's residual-scope note documents.
|
|
238
292
|
return {
|
|
239
293
|
id: existing.id,
|
|
240
294
|
status: existing.confirmed_at == null ? "already-pending" : "already-confirmed",
|
|
@@ -427,6 +481,62 @@ function create(opts) {
|
|
|
427
481
|
return rows;
|
|
428
482
|
},
|
|
429
483
|
|
|
484
|
+
// exportForCustomer({ customer_id?, email_hash? }) — subject-access
|
|
485
|
+
// (Art. 15) read. A subscription is keyed to a person by EITHER
|
|
486
|
+
// customer_id (a signed-in subscriber) OR the module's own
|
|
487
|
+
// stock-alert-namespace email hash (an anonymous subscriber). Both
|
|
488
|
+
// keys are matched with OR so a caller holding both covers every row;
|
|
489
|
+
// a caller resolving the subject by customer_id alone (the DSR
|
|
490
|
+
// composition root — no raw email is held anywhere to re-hash under
|
|
491
|
+
// this namespace) covers every account-linked row. The exported shape
|
|
492
|
+
// includes `email_normalised` — the subject's own address, stored
|
|
493
|
+
// plaintext by design for the notification dispatcher — and excludes
|
|
494
|
+
// the confirmation / unsubscribe token hashes (bearer credentials,
|
|
495
|
+
// not subject data).
|
|
496
|
+
exportForCustomer: async function (input) {
|
|
497
|
+
var sel = _dsrSelector(input, "exportForCustomer");
|
|
498
|
+
var w = _dsrWhere(sel);
|
|
499
|
+
return (await query(
|
|
500
|
+
"SELECT id, email_hash, email_normalised, sku, variant_id, customer_id, " +
|
|
501
|
+
"confirmed_at, notified_at, expires_at, created_at " +
|
|
502
|
+
"FROM stock_alerts WHERE " + w.clause +
|
|
503
|
+
" ORDER BY created_at DESC, id DESC LIMIT ?" + (w.params.length + 1),
|
|
504
|
+
w.params.concat([MAX_LIST_LIMIT]),
|
|
505
|
+
)).rows;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
// eraseForCustomer({ customer_id?, email_hash?, dry_run? }) — GDPR
|
|
509
|
+
// Art. 17 erasure. A stock alert is pure convenience data holding a
|
|
510
|
+
// PLAINTEXT email with no retention basis, so erasure hard-DELETEs
|
|
511
|
+
// the rows (which also frees the (email, sku, variant) unique-index
|
|
512
|
+
// slot cleanly — the person can re-subscribe later). Same dual-key
|
|
513
|
+
// selector as the export so the two never diverge on which rows
|
|
514
|
+
// belong to the subject. `dry_run: true` counts the rows a wet run
|
|
515
|
+
// WOULD delete without mutating. Returns the DSR reader contract
|
|
516
|
+
// shape `{ table, deleted }`.
|
|
517
|
+
//
|
|
518
|
+
// Residual scope, stated rather than implied: rows subscribed
|
|
519
|
+
// anonymously (customer_id NULL) are reachable only via the
|
|
520
|
+
// email_hash key — a customer_id-only call cannot match them because
|
|
521
|
+
// the system never holds the raw address to re-derive this
|
|
522
|
+
// namespace's hash. Those rows stay covered by the retention TTL
|
|
523
|
+
// (default 90 days, swept by cleanupExpired) and by the per-row
|
|
524
|
+
// bearer unsubscribe token the subscriber holds.
|
|
525
|
+
eraseForCustomer: async function (input) {
|
|
526
|
+
var dryRun = !!(input && input.dry_run);
|
|
527
|
+
var sel = _dsrSelector(input, "eraseForCustomer");
|
|
528
|
+
var w = _dsrWhere(sel);
|
|
529
|
+
if (dryRun) {
|
|
530
|
+
var c = (await query(
|
|
531
|
+
"SELECT COUNT(*) AS n FROM stock_alerts WHERE " + w.clause,
|
|
532
|
+
w.params,
|
|
533
|
+
)).rows[0];
|
|
534
|
+
return { table: "stock_alerts", deleted: c ? Number(c.n) : 0 };
|
|
535
|
+
}
|
|
536
|
+
var r = await query("DELETE FROM stock_alerts WHERE " + w.clause, w.params);
|
|
537
|
+
return { table: "stock_alerts", deleted: Number((r && r.rowCount) || 0) };
|
|
538
|
+
},
|
|
539
|
+
|
|
430
540
|
// Operator-driven sweeper. Walks every pending (confirmed +
|
|
431
541
|
// un-notified + un-expired) subscription whose SKU now has
|
|
432
542
|
// catalog.inventory.stock_on_hand - stock_held > 0 and:
|
package/lib/storefront.js
CHANGED
|
@@ -21025,11 +21025,22 @@ function mount(router, deps) {
|
|
|
21025
21025
|
var body = req.body || {};
|
|
21026
21026
|
var cartCount = 0;
|
|
21027
21027
|
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
21028
|
+
// Link the subscription to the signed-in customer when there is one,
|
|
21029
|
+
// so the row joins the account's privacy export / erasure scope. A
|
|
21030
|
+
// missing, stale, or revoked auth cookie reads as signed-out and the
|
|
21031
|
+
// anonymous subscribe (the primary, edge-served PDP flow — this route
|
|
21032
|
+
// stays in EDGE_POST_PATHS, no auth required) is unchanged.
|
|
21033
|
+
var saCustomerId = null;
|
|
21034
|
+
try {
|
|
21035
|
+
var saEnv = _currentCustomerEnv(req);
|
|
21036
|
+
if (saEnv && !(await _sessionRevoked(saEnv))) saCustomerId = saEnv.customer_id;
|
|
21037
|
+
} catch (_e) { saCustomerId = null; }
|
|
21028
21038
|
try {
|
|
21029
21039
|
var result = await deps.stockAlerts.subscribe({
|
|
21030
|
-
email:
|
|
21031
|
-
sku:
|
|
21032
|
-
variant_id:
|
|
21040
|
+
email: body.email,
|
|
21041
|
+
sku: body.sku,
|
|
21042
|
+
variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
|
|
21043
|
+
customer_id: saCustomerId,
|
|
21033
21044
|
});
|
|
21034
21045
|
// Only a brand-new subscription carries a plaintext token. Send the
|
|
21035
21046
|
// confirmation email best-effort; a mailer hiccup must not 500 the
|
package/package.json
CHANGED