@blamejs/blamejs-shop 0.4.50 → 0.4.52

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.4.x
10
10
 
11
+ - 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
+
13
+ - 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).
14
+
11
15
  - v0.4.50 (2026-06-14) — **Refresh the vendored blamejs framework to 0.15.11.** Refreshes the vendored blamejs framework from 0.15.9 to 0.15.11 (through 0.15.10). 0.15.11 replaces a family of quadratic-time regexes that hostile input could exploit to stall a worker with linear-time scans, refuses a relocatable sealed-cell downgrade on the read side, fails closed when row-level security is enabled behind a non-native driver, and verifies the vendored crypto against a reviewed pin. 0.15.10 makes S3 Object-Lock versioned erasure reachable through the object store — a versioned delete that targets a specific object version (refused under an active retention rather than silently writing a delete-marker), plus version enumeration for right-to-erasure workflows — and pins the build toolchain's native bundler binary to a reviewed hash. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and the security posture aligned with the latest release. **Changed:** *Vendored blamejs refreshed to 0.15.11* — The bundled framework is updated to blamejs 0.15.11. Across 0.15.10 and 0.15.11 it hardens a family of regular expressions against quadratic-time blow-up on hostile input (linear-time scans), refuses a relocatable sealed-cell downgrade when reading, fails closed when row-level security is enabled behind a driver that can't enforce it, adds versioned object erasure for S3 Object-Lock buckets (a versioned delete that refuses an active retention instead of leaving the data behind a delete-marker, plus version enumeration), and pins the build toolchain's native binary to a reviewed hash. Storefront and admin behaviour is unchanged by the refresh; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
12
16
 
13
17
  - v0.4.49 (2026-06-14) — **A stock write-off or audit adjustment can no longer strand a paid hold by dropping on-hand below what's reserved.** An operator stock write-off and a cycle-count audit adjustment both debit on-hand stock through the location adjustment, which guarded only against the shelf going below zero — not below the quantity already reserved by outstanding held allocations. So a write-off (or an audit-applied negative variance) could drop a location's on-hand below its outstanding holds, and when one of those holds later committed at fulfillment its debit would fail, stranding a paid order that could no longer be picked. Write-offs and audit adjustments now refuse a debit that would push on-hand below the held quantity for that SKU at that location (un-pinned holds count against every location), enforced inside the write so concurrent debits can't slip past it. A hold's own commit debit is unaffected — that stock is already reserved to it. No migration to apply. **Fixed:** *Stock write-offs and audit adjustments respect reserved holds* — The location stock adjustment now takes a hold-respecting mode used by write-offs and audit variance application: a debit is refused when it would drop on-hand below the outstanding held quantity for the SKU at that location, evaluated atomically as part of the write (un-pinned holds count against every location, matching the availability rule). Previously these operator adjustments only prevented on-hand from going negative, so they could shrink the shelf below what paid/committed holds had reserved — and the hold's later commit would then fail at fulfillment. Committing a hold still debits normally, since that stock is already reserved to it.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.50",
2
+ "version": "0.4.52",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -131,6 +131,7 @@ var CSV_COLUMNS = Object.freeze([
131
131
  "consent_kind",
132
132
  "state",
133
133
  "source",
134
+ "lawful_basis",
134
135
  "jurisdiction",
135
136
  "evidence_ref",
136
137
  "occurred_at",
@@ -138,6 +139,32 @@ var CSV_COLUMNS = Object.freeze([
138
139
 
139
140
  var b = require("./vendor/blamejs");
140
141
 
142
+ // The GDPR Art. 6(1) lawful bases, sourced from the framework's
143
+ // consent primitive so this ledger's vocabulary tracks the
144
+ // primitive's ground truth instead of re-listing the six strings.
145
+ var LAWFUL_BASES = b.consent.LAWFUL_BASES;
146
+
147
+ // Every consent kind this ledger records is consent-gated: cookies
148
+ // are non-essential under ePrivacy Art. 5(3) (the strictly-necessary
149
+ // set lives in cookieConsent, never here), marketing email / SMS rest
150
+ // on Art. 6(1)(a), and third-party data sharing is not a contract
151
+ // necessity. data_processing is the opt-in catch-all — contract /
152
+ // legal-obligation processing is recorded by the domain table that
153
+ // owns it, not by this opt-in ledger. So each kind maps to "consent".
154
+ // A caller may still pass an explicit, validated lawful_basis to
155
+ // override the default for a row that genuinely rests on another basis.
156
+ var KIND_TO_LAWFUL_BASIS = Object.freeze({
157
+ cookies_functional: "consent",
158
+ cookies_analytics: "consent",
159
+ cookies_marketing: "consent",
160
+ cookies_preferences: "consent",
161
+ marketing_email: "consent",
162
+ marketing_sms: "consent",
163
+ data_sharing_partners: "consent",
164
+ data_sharing_analytics: "consent",
165
+ data_processing: "consent",
166
+ });
167
+
141
168
  // ---- monotonic clock ----------------------------------------------------
142
169
  //
143
170
  // Operator-driven writes can land in the same millisecond on fast
@@ -195,6 +222,16 @@ function _source(s) {
195
222
  return s;
196
223
  }
197
224
 
225
+ function _lawfulBasis(s) {
226
+ if (typeof s !== "string" || LAWFUL_BASES.indexOf(s) === -1) {
227
+ throw new TypeError(
228
+ "consentLedger: lawful_basis must be one of " + LAWFUL_BASES.join(", ") +
229
+ ", got " + JSON.stringify(s)
230
+ );
231
+ }
232
+ return s;
233
+ }
234
+
198
235
  function _optJurisdiction(s) {
199
236
  if (s == null || s === "") return null;
200
237
  if (typeof s !== "string") {
@@ -282,6 +319,7 @@ function _rowToRecord(row) {
282
319
  consent_kind: row.consent_kind,
283
320
  state: row.state,
284
321
  source: row.source,
322
+ lawful_basis: row.lawful_basis == null ? null : row.lawful_basis,
285
323
  jurisdiction: row.jurisdiction == null ? null : row.jurisdiction,
286
324
  evidence_ref: row.evidence_ref == null ? null : row.evidence_ref,
287
325
  occurred_at: Number(row.occurred_at),
@@ -327,15 +365,23 @@ function create(opts) {
327
365
  var source = _source(input.source);
328
366
  var jurisdiction = _optJurisdiction(input.jurisdiction);
329
367
  var evidenceRef = _optEvidenceRef(input.evidence_ref);
368
+ // Lawful basis defaults from the kind (every kind here is
369
+ // consent-gated; the basis is state-agnostic so a withdrawal row
370
+ // carries the same basis as its grant). An explicit lawful_basis is
371
+ // validated and overrides the default for a row that rests on a
372
+ // different Art. 6 basis.
373
+ var lawfulBasis = input.lawful_basis == null
374
+ ? KIND_TO_LAWFUL_BASIS[consentKind]
375
+ : _lawfulBasis(input.lawful_basis);
330
376
 
331
377
  var id = b.uuid.v7();
332
378
  var ts = _now();
333
379
 
334
380
  await query(
335
381
  "INSERT INTO consent_ledger " +
336
- "(id, customer_id, consent_kind, state, source, jurisdiction, evidence_ref, occurred_at) " +
337
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
338
- [id, customerId, consentKind, state, source, jurisdiction, evidenceRef, ts],
382
+ "(id, customer_id, consent_kind, state, source, lawful_basis, jurisdiction, evidence_ref, occurred_at) " +
383
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
384
+ [id, customerId, consentKind, state, source, lawfulBasis, jurisdiction, evidenceRef, ts],
339
385
  );
340
386
 
341
387
  return {
@@ -344,6 +390,7 @@ function create(opts) {
344
390
  consent_kind: consentKind,
345
391
  state: state,
346
392
  source: source,
393
+ lawful_basis: lawfulBasis,
347
394
  jurisdiction: jurisdiction,
348
395
  evidence_ref: evidenceRef,
349
396
  occurred_at: ts,
package/lib/storefront.js CHANGED
@@ -7589,6 +7589,7 @@ var ORDER_PAGE =
7589
7589
  " </div>\n" +
7590
7590
  " <aside class=\"order-page__totals\">\n" +
7591
7591
  " RAW_ORDER_TIMELINE" +
7592
+ " RAW_ORDER_EVENTS" +
7592
7593
  " RAW_ORDER_TRACKING" +
7593
7594
  " <h2 class=\"pdp__variants-title\">Totals</h2>\n" +
7594
7595
  " <dl class=\"totals-list\">\n" +
@@ -7717,6 +7718,135 @@ function _orderTimelineBlock(status) {
7717
7718
  "<ol class=\"order-timeline__steps\">" + steps + "</ol></div>";
7718
7719
  }
7719
7720
 
7721
+ // The customer-facing rich event timeline reads the order's own
7722
+ // transition ledger (order.get() hydrates `order.transitions` from
7723
+ // `order_transitions`, oldest-first). Each row carries an `on_event`,
7724
+ // an `occurred_at`, and a `metadata_json` blob; this block flattens the
7725
+ // ledger into a readable, dated, chronological feed — placed, paid,
7726
+ // shipped, delivered, refunded (with amounts), cancelled — that the
7727
+ // customer sees alongside the status rail.
7728
+ //
7729
+ // Customer-meaningful events only. The FSM also records mid-fulfillment
7730
+ // bookkeeping (`start_fulfillment` — the warehouse picking the parcel)
7731
+ // and the `__init__` placeholder edge; neither is something a customer
7732
+ // acts on, so both stay out of the feed. The raw `from_state →
7733
+ // to_state` string, the internal `reason` (operator-process strings
7734
+ // like `admin:console` / `stale-pending-reap`), and the rest of the
7735
+ // metadata blob are NEVER rendered — only the per-event detail the
7736
+ // customer cares about (a refund amount, a tracking number) is pulled
7737
+ // out, by key, and escaped at the sink.
7738
+ var ORDER_EVENT_LABELS = {
7739
+ "create": "Order placed",
7740
+ "mark_paid": "Payment received",
7741
+ "mark_shipped": "Order shipped",
7742
+ "mark_delivered": "Order delivered",
7743
+ "cancel": "Order cancelled",
7744
+ // `refund` covers both a partial refund (a same-state self-loop row)
7745
+ // and the terminal balance-clearing refund; the amount in metadata
7746
+ // distinguishes them in the detail line.
7747
+ "refund": "Refund issued",
7748
+ };
7749
+
7750
+ // Events the customer sees on their own order page. Everything not in
7751
+ // this set (start_fulfillment, any future internal-only FSM edge) is
7752
+ // dropped from the customer feed.
7753
+ var ORDER_EVENT_CUSTOMER_VISIBLE = {
7754
+ "create": true,
7755
+ "mark_paid": true,
7756
+ "mark_shipped": true,
7757
+ "mark_delivered": true,
7758
+ "cancel": true,
7759
+ "refund": true,
7760
+ };
7761
+
7762
+ // Format an order-transition `occurred_at` (epoch ms) the same way the
7763
+ // shipment timeline does — "YYYY-MM-DD HH:MM" UTC — so the two feeds
7764
+ // read consistently. Returns "" for an absent/garbage timestamp.
7765
+ function _orderEventWhen(occurredAt) {
7766
+ if (occurredAt == null) return "";
7767
+ var n = Number(occurredAt);
7768
+ if (!isFinite(n)) return "";
7769
+ return new Date(n).toISOString().slice(0, 16).replace("T", " ");
7770
+ }
7771
+
7772
+ // Build the per-event customer-facing detail line from the transition's
7773
+ // metadata — by key, never the raw blob. A refund row surfaces its
7774
+ // amount (formatted in the order currency); a shipped row surfaces a
7775
+ // carrier + tracking number when the metadata carried one. Everything
7776
+ // returned here is escaped by the caller at the render sink. Returns ""
7777
+ // when there's no customer-relevant detail for the event.
7778
+ function _orderEventDetail(onEvent, meta, currency) {
7779
+ if (!meta || typeof meta !== "object") return "";
7780
+ if (onEvent === "refund") {
7781
+ var amt = meta.amount_minor;
7782
+ if (Number.isInteger(amt) && amt > 0) {
7783
+ var label = meta.partial === true ? "Partial refund: " : "Amount: ";
7784
+ return label + pricing.format(amt, currency || "USD");
7785
+ }
7786
+ return "";
7787
+ }
7788
+ if (onEvent === "mark_shipped") {
7789
+ var parts = [];
7790
+ if (meta.carrier) parts.push(String(meta.carrier));
7791
+ if (meta.tracking_number) parts.push("Tracking " + String(meta.tracking_number));
7792
+ return parts.join(" · ");
7793
+ }
7794
+ if (onEvent === "mark_delivered") {
7795
+ // A click-and-collect delivery stamps a pickup location into the
7796
+ // metadata; surface it as the delivered-detail when present.
7797
+ if (meta.pickup_location_code) return "Picked up at " + String(meta.pickup_location_code);
7798
+ return "";
7799
+ }
7800
+ return "";
7801
+ }
7802
+
7803
+ // Render the rich, dated, chronological event timeline from the order's
7804
+ // transition ledger. Newest-first. Each event is a label + a UTC
7805
+ // timestamp + an optional customer-relevant detail (refund amount,
7806
+ // tracking number, pickup location). Internal-only transitions
7807
+ // (start_fulfillment, the __init__ placeholder, unknown future events)
7808
+ // are filtered out. Every data-derived value — label, detail, time — is
7809
+ // HTML-escaped at the sink, matching the escape-by-default convention of
7810
+ // the rest of this file. Returns "" when the order carries no
7811
+ // customer-visible transitions (the status rail above still renders), so
7812
+ // a brand-new order with a single placed event still shows that event.
7813
+ function _orderEventTimelineBlock(o) {
7814
+ if (!o || !Array.isArray(o.transitions) || !o.transitions.length) return "";
7815
+ var esc = b.template.escapeHtml;
7816
+ var currency = o.currency || "USD";
7817
+ var events = [];
7818
+ for (var i = 0; i < o.transitions.length; i += 1) {
7819
+ var t = o.transitions[i];
7820
+ if (!t || !ORDER_EVENT_CUSTOMER_VISIBLE[t.on_event]) continue;
7821
+ var meta = {};
7822
+ try { meta = JSON.parse(t.metadata_json || "{}"); }
7823
+ catch (_e) { meta = {}; } // drop-silent — bad JSON falls through to no-detail
7824
+ events.push({
7825
+ label: ORDER_EVENT_LABELS[t.on_event] || t.on_event,
7826
+ occurred_at: Number(t.occurred_at) || 0,
7827
+ detail: _orderEventDetail(t.on_event, meta, currency),
7828
+ });
7829
+ }
7830
+ if (!events.length) return "";
7831
+ // Newest-first for display. The ledger arrives oldest-first; sort a
7832
+ // copy descending by occurred_at, stable on the original order for ties
7833
+ // (same-ms events keep ledger sequence, reversed) so the feed is
7834
+ // deterministic across runs.
7835
+ events.reverse();
7836
+ events.sort(function (a, c) { return c.occurred_at - a.occurred_at; });
7837
+ var rows = events.map(function (e) {
7838
+ var when = _orderEventWhen(e.occurred_at);
7839
+ return "<li class=\"order-events__event\">" +
7840
+ "<span class=\"order-events__label\">" + esc(e.label) + "</span>" +
7841
+ (e.detail ? " <span class=\"order-events__detail\">" + esc(e.detail) + "</span>" : "") +
7842
+ (when ? " <time class=\"order-events__when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
7843
+ "</li>";
7844
+ }).join("");
7845
+ return "<div class=\"order-events\">" +
7846
+ "<h2 class=\"pdp__variants-title\">Order activity</h2>" +
7847
+ "<ol class=\"order-events__list\">" + rows + "</ol></div>";
7848
+ }
7849
+
7720
7850
  // Cap on how many carrier events the per-shipment timeline renders. A
7721
7851
  // long-haul international parcel can accumulate dozens of scans; the
7722
7852
  // customer-facing panel shows the most recent MAX_SHIPMENT_TIMELINE
@@ -8142,6 +8272,13 @@ function renderOrder(opts) {
8142
8272
  // the order status alone, so they render even without tracking wired.
8143
8273
  var shipments = opts.shipments || [];
8144
8274
  var timelineHtml = _orderTimelineBlock(o.status);
8275
+ // Rich, dated, chronological event feed from the order's own
8276
+ // transition ledger (order.get() hydrates o.transitions). Read-only,
8277
+ // customer-visible events only, every value escaped at the sink. Empty
8278
+ // string for an order whose ledger wasn't hydrated (the status rail
8279
+ // above still renders), so legacy callers that pass no transitions are
8280
+ // unaffected.
8281
+ var eventsHtml = _orderEventTimelineBlock(o);
8145
8282
  var trackingHtml = _orderTrackingBlock(shipments);
8146
8283
  // The receipt-download link carries the guest order's ?k= access token when
8147
8284
  // the page was opened from the emailed capability link, so the download
@@ -8174,6 +8311,7 @@ function renderOrder(opts) {
8174
8311
  total: total,
8175
8312
  ship_to: o.ship_to || null,
8176
8313
  timeline_html: timelineHtml,
8314
+ events_html: eventsHtml,
8177
8315
  tracking_html: trackingHtml,
8178
8316
  pickup_html: _orderPickupBlock(opts.pickup),
8179
8317
  gift_html: _orderGiftBlock(opts.gift_options),
@@ -8229,9 +8367,16 @@ function renderOrder(opts) {
8229
8367
  .replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
8230
8368
  .replace("RAW_ORDER_TIMELINE", timelineHtml)
8231
8369
  .replace("RAW_ORDER_TRACKING", trackingHtml)
8370
+ .replace("RAW_ORDER_EVENTS", "RAW_ORDER_EVENTS_PLACEHOLDER")
8232
8371
  .replace("RAW_ORDER_PICKUP", _orderPickupBlock(opts.pickup))
8233
8372
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
8234
8373
  .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
8374
+ // The event timeline carries operator-influenced free text (carrier
8375
+ // names, tracking numbers — already escaped into the block, but a `$`
8376
+ // in that escaped text would still trip String.replace's dollar
8377
+ // substitution) — splice it via the replacer-function helper, never a
8378
+ // replacement-string .replace.
8379
+ body = _spliceRaw(body, "RAW_ORDER_EVENTS_PLACEHOLDER", eventsHtml);
8235
8380
  // The gift block carries customer free text (escaped, but a `$` in it would
8236
8381
  // still trip String.replace's dollar substitution) — splice it via the
8237
8382
  // replacer-function helper, never a replacement-string .replace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.50",
3
+ "version": "0.4.52",
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": {