@blamejs/blamejs-shop 0.4.52 → 0.4.53
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/lib/asset-manifest.json +1 -1
- package/lib/newsletter.js +9 -0
- package/lib/storefront.js +90 -0
- 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.53 (2026-06-14) — **A signed-in customer's cookie choices and newsletter unsubscribe now land in the durable consent record.** The durable, per-customer consent ledger — the GDPR Article 7(1) record a controller keeps to demonstrate consent — is now written from the real consent events for identified customers. When a signed-in customer saves their cookie preferences, each category (functional, analytics, marketing, preferences) is recorded as granted or withdrawn in the durable ledger, alongside the existing session-level cookie record. When a newsletter unsubscribe resolves to a customer account, a marketing-email withdrawal is recorded there too. Anonymous visitors and email-only subscribers with no account are unchanged — their cookie choice stays in the session-level store and their unsubscribe in the email-suppression list, neither of which can be customer-keyed. The ledger writes are best-effort and never block the banner save or the unsubscribe. No migration to apply. **Added:** *Cookie-banner choices recorded in the durable consent ledger for signed-in customers* — When an authenticated customer saves their cookie preferences, each of the four optional categories is now mirrored into the durable per-customer consent ledger as a granted or withdrawn decision (source: cookie banner), so a supervisory-authority audit shows the identified individual's choice and not only the session-keyed record. The durable record reflects the consent the storefront actually enforces: a browser-level opt-out signal (Global Privacy Control or Do Not Track) collapses the analytics and marketing categories to withdrawn even if their boxes were ticked, matching the runtime gate, so the record never claims consent the app refuses to honor. The customer is resolved from the existing signed-in session (a revoked session is treated as signed-out); anonymous visitors carry no account and are written only to the session-level cookie record as before. · *Newsletter unsubscribe records a marketing withdrawal for account holders* — A newsletter unsubscribe that resolves to a customer account now records a marketing-email withdrawal in the durable consent ledger. The unsubscribed address is matched to an account by its hashed form; an email-only subscriber with no account is handled by the existing email-suppression path only, since the durable ledger is keyed by customer. The existing unsubscribe behavior — the suppression entry and the one-click RFC 8058 flow — is unchanged.
|
|
12
|
+
|
|
11
13
|
- v0.4.52 (2026-06-14) — **Consent records now carry the GDPR Article 6 lawful basis they rest on.** Each decision in the durable per-customer consent ledger now records the GDPR Article 6(1) lawful basis it is processed under — consent, contract, legal obligation, vital interests, public task, or legitimate interests — alongside what was decided and when. The basis defaults from the consent kind (cookie categories, marketing email/SMS, and data-sharing opt-ins are all consent-based), and a caller may pass an explicit, validated basis for a record that rests on another. The basis is surfaced in the subject-access and supervisory-authority exports, so a consent audit now shows not just the decision but the legal ground for it. A migration adds a nullable, constrained lawful_basis column; pre-existing rows keep a null basis rather than being assigned one retroactively. Apply the pending migration on upgrade. **Added:** *Lawful basis on consent-ledger records* — The consent ledger now stamps the GDPR Article 6(1) lawful basis on every recorded decision. The basis defaults from the consent kind — every category the ledger tracks (cookie functional/analytics/marketing/preferences, marketing email, marketing SMS, partner and analytics data-sharing, and general data processing) is consent-based — and an explicit basis can be supplied and is validated against the six Article 6(1) values. Withdrawal records carry the same basis as the grant they revoke. The basis is included in the subject-access export (CSV and JSON) and the jurisdiction bulk export, so a consent audit shows the legal ground each decision rests on. **Changed:** *Migration: lawful_basis column on consent_ledger* — A new migration adds a nullable lawful_basis column to consent_ledger, constrained to the six Article 6(1) bases. The column is nullable and the constraint applies only to rows written after the migration, so existing records keep a null basis (not retroactively assigned) while every new record carries one. Apply the pending migration on upgrade.
|
|
12
14
|
|
|
13
15
|
- v0.4.51 (2026-06-14) — **The order page now shows a dated activity timeline of the order's lifecycle.** A customer viewing their order now sees a chronological activity feed of what has happened to it — placed, payment received, shipped, delivered, cancelled, and refunds — newest first, each with its date and the relevant detail. A shipped event shows the carrier and tracking number when one was recorded; a refund shows its amount (and marks a partial refund as such); a click-and-collect delivery shows the pickup location. The feed is built from the order's own transition history and is scoped to the order being viewed, so it appears only to the order's owner or a guest holding the order's access link. Internal fulfillment bookkeeping and operator-process detail are not surfaced — only customer-meaningful milestones. The order page renders this server-side with no client JavaScript. No migration to apply. **Added:** *Order activity timeline on the order page* — The order detail page renders a dated, newest-first timeline of the order's lifecycle events: placed, payment received, shipped (with carrier and tracking number when present), delivered (with pickup location for click-and-collect), cancelled, and refunds (with the refunded amount, labelled partial when applicable). Events are drawn from the order's transition history and filtered to customer-meaningful milestones — internal fulfillment steps, raw state transitions, operator-process reasons, and payment-provider identifiers are never shown. Every rendered value is HTML-escaped, and the feed is shown only within the order's existing access scope (the signed-in owner or a guest order's access link).
|
package/lib/asset-manifest.json
CHANGED
package/lib/newsletter.js
CHANGED
|
@@ -303,6 +303,15 @@ function create(opts) {
|
|
|
303
303
|
error: "ok",
|
|
304
304
|
signup_id: row.signup_id,
|
|
305
305
|
email_hash: signup ? signup.email_hash : null,
|
|
306
|
+
// The newsletter `email_hash` is keyed on the "newsletter-email"
|
|
307
|
+
// namespace and therefore never matches a customers-store lookup
|
|
308
|
+
// (which keys on "customer-email"). The plaintext address is what
|
|
309
|
+
// lets a caller re-derive the customer-namespace hash and decide
|
|
310
|
+
// whether this unsubscribe belongs to a real account (e.g. to
|
|
311
|
+
// record a durable per-customer marketing_email withdrawal). The
|
|
312
|
+
// signup row already persists `email_normalized` to act on the
|
|
313
|
+
// address; surface it here verbatim.
|
|
314
|
+
email_normalized: signup ? signup.email_normalized : null,
|
|
306
315
|
};
|
|
307
316
|
},
|
|
308
317
|
|
package/lib/storefront.js
CHANGED
|
@@ -21134,6 +21134,42 @@ function mount(router, deps) {
|
|
|
21134
21134
|
// "already" — still a success page, no error).
|
|
21135
21135
|
var result = await deps.newsletter.consumeUnsubscribeToken(token);
|
|
21136
21136
|
outcome = _unsubscribeOutcome(result);
|
|
21137
|
+
|
|
21138
|
+
// Durable per-CUSTOMER consent of record (GDPR Art. 7(1)). The
|
|
21139
|
+
// newsletter table is email-keyed — it has no customer_id — and its
|
|
21140
|
+
// own opt-out (the unsubscribed_at stamp + the email-suppression
|
|
21141
|
+
// entry) is unchanged above. But when the unsubscribed address ALSO
|
|
21142
|
+
// belongs to a real customer account, the withdrawal is a decision
|
|
21143
|
+
// by an identified data subject, so it lands in the durable,
|
|
21144
|
+
// customer-keyed consent_ledger as a marketing_email withdrawal.
|
|
21145
|
+
//
|
|
21146
|
+
// The consume result's `email_hash` is keyed on the newsletter
|
|
21147
|
+
// namespace and therefore never matches the customers store (which
|
|
21148
|
+
// keys on a DIFFERENT namespace) — so resolution goes through the
|
|
21149
|
+
// plaintext `email_normalized`, re-hashed under the customers
|
|
21150
|
+
// namespace via customers.hashEmail. An email-only newsletter
|
|
21151
|
+
// subscriber with no account resolves to null and is intentionally
|
|
21152
|
+
// NOT written to the ledger (it can't be customer-keyed) — only the
|
|
21153
|
+
// existing email-suppression applies. Drop-silent + best-effort: the
|
|
21154
|
+
// unsubscribe already committed; an audit-trail write must never turn
|
|
21155
|
+
// it into an error.
|
|
21156
|
+
if (result && result.ok && result.email_normalized &&
|
|
21157
|
+
deps.consentLedger && deps.customers &&
|
|
21158
|
+
typeof deps.customers.byEmailHash === "function" &&
|
|
21159
|
+
typeof deps.customers.hashEmail === "function") {
|
|
21160
|
+
try {
|
|
21161
|
+
var unsubCustHash = deps.customers.hashEmail(result.email_normalized);
|
|
21162
|
+
var unsubCustomer = await deps.customers.byEmailHash(unsubCustHash);
|
|
21163
|
+
if (unsubCustomer && unsubCustomer.id) {
|
|
21164
|
+
await deps.consentLedger.recordConsentChange({
|
|
21165
|
+
customer_id: unsubCustomer.id,
|
|
21166
|
+
consent_kind: "marketing_email",
|
|
21167
|
+
state: "withdrawn",
|
|
21168
|
+
source: "preference_center",
|
|
21169
|
+
});
|
|
21170
|
+
}
|
|
21171
|
+
} catch (_eLedger) { /* drop-silent — the unsubscribe is authoritative; the durable ledger is the audit trail and must not block it */ }
|
|
21172
|
+
}
|
|
21137
21173
|
} catch (e) {
|
|
21138
21174
|
// A real infrastructure fault (D1 unreachable) — record it server-
|
|
21139
21175
|
// side and render the generic non-leaking page rather than a 500
|
|
@@ -21425,6 +21461,60 @@ function mount(router, deps) {
|
|
|
21425
21461
|
} catch (_e) { /* drop-silent — the gate cookie is authoritative; the ledger write is the audit trail and must not block the decision */ }
|
|
21426
21462
|
}
|
|
21427
21463
|
|
|
21464
|
+
// Durable per-CUSTOMER consent of record (GDPR Art. 7(1)). The
|
|
21465
|
+
// cookieConsent ledger above is session-keyed (hashed sid); it proves
|
|
21466
|
+
// what a browser chose, not what an identified data subject chose. When
|
|
21467
|
+
// the visitor is an authenticated customer, ALSO mirror each cookie
|
|
21468
|
+
// category into the durable, customer-keyed consent_ledger so a
|
|
21469
|
+
// supervisory-authority audit can demonstrate the named individual's
|
|
21470
|
+
// decision. Anonymous / session-only visitors carry no customer_id and
|
|
21471
|
+
// MUST NOT touch this ledger (recordConsentChange refuses a non-UUID),
|
|
21472
|
+
// so the resolution below skips them and only the session-level record
|
|
21473
|
+
// above is written. The resolver mirrors the stock-alert subscribe
|
|
21474
|
+
// path: _currentCustomerEnv reads the sealed auth envelope; a present-
|
|
21475
|
+
// but-revoked session is treated as signed-out so a withdrawn session
|
|
21476
|
+
// can't keep stamping the ledger.
|
|
21477
|
+
if (deps.consentLedger) {
|
|
21478
|
+
var clCustomerId = null;
|
|
21479
|
+
try {
|
|
21480
|
+
var clEnv = _currentCustomerEnv(req);
|
|
21481
|
+
if (clEnv && !(await _sessionRevoked(clEnv))) clCustomerId = clEnv.customer_id;
|
|
21482
|
+
} catch (_eAuth) { clCustomerId = null; }
|
|
21483
|
+
if (clCustomerId) {
|
|
21484
|
+
// Map the four toggleable cookie categories to their consent_ledger
|
|
21485
|
+
// kinds; on = granted, off = withdrawn. Each write is independent
|
|
21486
|
+
// and drop-silent so a single failure can't strand the others or
|
|
21487
|
+
// block the banner save.
|
|
21488
|
+
// A browser-level opt-out (GPC / DNT) forces analytics + marketing
|
|
21489
|
+
// off no matter what the posted checkbox says — exactly the collapse
|
|
21490
|
+
// the runtime gate _consentAllows applies. The durable ledger has no
|
|
21491
|
+
// signal column, so it must record the EFFECTIVE consent the app
|
|
21492
|
+
// enforces, not the raw click: otherwise the customer record would
|
|
21493
|
+
// claim consent for a category the app treats as withdrawn.
|
|
21494
|
+
var clOptOut = _browserOptOut(req);
|
|
21495
|
+
var clMap = [
|
|
21496
|
+
["functional", "cookies_functional"],
|
|
21497
|
+
["analytics", "cookies_analytics"],
|
|
21498
|
+
["marketing", "cookies_marketing"],
|
|
21499
|
+
["preferences", "cookies_preferences"],
|
|
21500
|
+
];
|
|
21501
|
+
for (var clI = 0; clI < clMap.length; clI += 1) {
|
|
21502
|
+
var clCat = clMap[clI][0];
|
|
21503
|
+
var clKind = clMap[clI][1];
|
|
21504
|
+
var clOn = cats[clCat] === true;
|
|
21505
|
+
if ((clCat === "analytics" || clCat === "marketing") && clOptOut) clOn = false;
|
|
21506
|
+
try {
|
|
21507
|
+
await deps.consentLedger.recordConsentChange({
|
|
21508
|
+
customer_id: clCustomerId,
|
|
21509
|
+
consent_kind: clKind,
|
|
21510
|
+
state: clOn ? "granted" : "withdrawn",
|
|
21511
|
+
source: "cookie_banner",
|
|
21512
|
+
});
|
|
21513
|
+
} catch (_eLedger) { /* drop-silent — the gate cookie is authoritative; the durable ledger is the audit trail and must not block the decision */ }
|
|
21514
|
+
}
|
|
21515
|
+
}
|
|
21516
|
+
}
|
|
21517
|
+
|
|
21428
21518
|
var dest = _consentReturnTo(body.return_to, "/");
|
|
21429
21519
|
res.status(303);
|
|
21430
21520
|
res.setHeader && res.setHeader("location", fromManage ? "/cookies?saved=1" : dest);
|
package/package.json
CHANGED