@blamejs/blamejs-shop 0.1.33 → 0.1.35
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 +1 -0
- package/lib/asset-manifest.json +7 -3
- package/lib/storefront.js +432 -4
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +3 -1
- package/lib/vendor/blamejs/api-snapshot.json +97 -2
- package/lib/vendor/blamejs/index.js +3 -0
- package/lib/vendor/blamejs/lib/crdt.js +453 -0
- package/lib/vendor/blamejs/lib/crypto-xwing.js +213 -0
- package/lib/vendor/blamejs/lib/iab-tcf.js +277 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.2.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.3.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.4.json +23 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crdt.test.js +158 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-xwing.test.js +120 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/iab-tcf.test.js +61 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.35 (2026-05-26) — **Cookie-consent banner — GDPR/ePrivacy consent with category opt-in, an audit ledger, and a preferences page.** Every page now carries a cookie-consent banner until the visitor decides. They can accept all, reject everything non-essential, or open preferences to toggle categories; the choice is stored in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies (session, CSRF, the consent choice itself) always work; analytics and marketing are opt-in and default-deny, and a Do-Not-Track or Global-Privacy-Control signal collapses them to denied regardless of any stored opt-in. A "Manage cookies" footer link reopens the preferences. The banner and its choice form are server-rendered and work with JavaScript off — the small enhancement script only hides the banner once a choice exists — and the banner renders identically from the edge and the container. A consent-gating hook lets future analytics/marketing scripts render only when their category is granted. **Added:** *Consent banner + preferences page* — A banner shows on every page until a choice is made: Accept all, Reject non-essential, or Manage preferences. `POST /consent` records the decision (validated; safe-redirect back to where the visitor was), and `GET /cookies` is a preferences page to review and change categories later, linked from the footer. The authoritative choice is a sealed, HttpOnly first-party cookie; a tampered or missing cookie simply reshows the banner rather than erroring. Every decision is also written to the consent ledger for the GDPR audit trail, best-effort so a ledger hiccup never blocks the choice from taking effect. · *Category opt-in with DNT / GPC honored* — Cookies are grouped into strictly-necessary (always on) plus toggleable categories (analytics, marketing, …) that are opt-in and default-deny. A `DNT: 1` or `Sec-GPC: 1` request header forces the non-essential categories to denied regardless of any stored opt-in. A server-side gating hook renders a category's script only when that category is granted, so future analytics/marketing islands are consent-gated by construction. · *Consent is anchored to a policy version* — Each stored decision records the cookie-policy version it was captured under. When an operator bumps the active version (the cookie policy materially changed), decisions taken under the old version stop being honored — the gate falls back to default-deny for the optional categories and the banner re-prompts so consent is re-collected, rather than coasting on opt-ins given under a superseded policy. The banner and preferences page keep working with JavaScript off; the enhancement script only suppresses the banner while the stored version still matches the active one.
|
|
12
|
+
|
|
13
|
+
- v0.1.34 (2026-05-26) — **Vendored blamejs refreshed to v0.13.0.** Refreshes the vendored blamejs stack from v0.12.11 to v0.13.0 — the single source of every security, crypto, and primitive the shop composes. No application API changes; the refresh keeps the shop current with upstream fixes and broadens the primitive surface the shop builds on. The vendored tree is verified against the release tag by the vendor-integrity gate, and the in-image smoke gate runs against the refreshed tree before any image ships. **Changed:** *Vendored blamejs v0.12.11 → v0.13.0* — The committed copy under `lib/vendor/blamejs/` is updated to the v0.13.0 release tag via the vendor-refresh entry point. Composed primitives are unchanged at the call sites the shop already uses; the refresh carries upstream maintenance and expands the available primitive surface for future shop features.
|
|
14
|
+
|
|
11
15
|
- v0.1.33 (2026-05-26) — **Search filters and synonyms — narrow results by facet, and match what shoppers mean, not just what they typed.** Search gains filters and smarter matching. A search result page now shows facet groups — collection, price range, and in-stock — as filter controls; choosing one narrows the results, the counts beside each option reflect the current result set, and choices combine across groups. Active filters show with a one-click clear and carry through pagination rather than dropping on page two. All filtering is server-side from query parameters, so it works with no client JavaScript. Separately, the query is expanded through an operator-curated synonym and typo vocabulary before the catalog is searched, so a search for "tee" matches "t-shirt" and a near-miss spelling still finds the product, with a "Showing results for" note when the query was corrected. Filters and synonym matching work identically whether the page is served from the edge or the container. This release also moves the shipped theme's stylesheets and island scripts to content-fingerprinted filenames so an asset and the page that references it can never fall out of sync. **Added:** *Faceted search filters* — A search result page shows facet groups — collection, price range, and in-stock availability — as server-rendered filter controls. Selecting a facet narrows the results and is reflected in the URL (for example `/search?q=shirt&collection=summer&in_stock=1`), the count beside each option reflects how many results it would yield against the current query, and facets combine across groups. Active filters render as removable chips with a clear-all path, and the active filters carry through result pagination so page two keeps the filter set. All filtering is server-side from the query string — no client JavaScript — and unknown facet keys, out-of-range price values, and garbage filter values are ignored rather than erroring. With filters applied and no matches, the page shows an empty state with a clear-filters link. · *Synonym and typo-tolerant query matching* — Before the catalog is searched, the query is expanded through an operator-curated vocabulary: synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal. When the query is corrected or expanded, the page shows a "Showing results for" note. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. **Changed:** *Search filters and synonyms render identically at the edge and the container* — The faceting and synonym logic runs in both the edge worker and the container search path, producing the same results and the same filter chrome whichever serves the page. The edge path reads the catalog and the facet/synonym vocabulary directly from the database, missing-table-resilient, so a shop without the optional facet configuration still searches normally. · *Theme stylesheets and scripts use content-fingerprinted filenames* — The shipped default theme's stylesheets and island scripts are now referenced by a content-hashed filename (`main.<hash>.css`) rather than a `?v=` query, emitted identically by the edge and container renderers and served from object storage under the fingerprinted key. Each URL maps to exactly one byte-content, so an asset and the page that references it can never disagree on a Subresource Integrity digest, and previously-served pages keep working across an asset change. The edge stylesheet link now also carries an integrity attribute, matching the container.
|
|
12
16
|
|
|
13
17
|
- v0.1.32 (2026-05-26) — **Bundles and quantity discounts — sell products together at a set price, and reward buying more.** Two merchandising surfaces go live on the product page and in the cart, both priced authoritatively on the server. A product that belongs to a bundle shows a "Bundle & save" offer with its member products and the bundle price against the sum of the parts; one action adds the whole bundle to the cart at that price, all-or-nothing, and the discount is split across the member lines so the cart subtotal equals the quoted bundle price. Separately, a product with quantity breaks shows its price tiers ("1–2 / 3–5 / 6+") on the page, and the cart applies the right unit price for each line's quantity — recomputed on every cart render and again at checkout, so the line, cart, and order totals all reflect the break. The client never sends a price: it sends a bundle code or a quantity, and the server prices both from the live catalog. A bundle whose member is archived or out of stock is shown unavailable rather than adding a broken line, and a missing offer simply renders nothing. **Added:** *Bundles on the product page* — A product that is part of one or more bundles shows a "Bundle & save" offer listing the bundle's member products, the bundle price, and the saving against buying the parts separately. `POST /cart/bundle` adds the whole bundle to the cart in one action: the bundle price is recomputed from the live catalog and allocated across the member lines (proportional to each member's list price, with the rounding remainder on the last line) so the cart subtotal equals the quoted price. The add is atomic — if any member is archived or out of stock the bundle is shown unavailable and the action adds nothing rather than a partial set. A product in no bundle shows no offer; an unpriceable bundle (a missing member price or a currency mismatch) is shown unavailable, never a server error. The offer renders identically from the edge worker and the container. · *Quantity discounts on the product page and in the cart* — A product with quantity breaks shows its price tiers on the page (for example 1–2, 3–5, 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — applied server-side on every cart render so changing the quantity re-prices the line, and applied again at checkout so the per-line price and the order total written onto the order reflect the break, not the list price. A quantity below the first tier falls back to the base price; a quantity above the top tier takes the top tier. Stacks with the rest of the cart math. The tier table renders identically from the edge worker and the container. **Fixed:** *Themed product template carries the compare control* — The file-backed default product template now includes the Add-to-compare slot, matching the built-in render path so the comparison control appears on the product page whichever template is in use.
|
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
80
80
|
| **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
|
|
81
81
|
| **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
|
|
82
82
|
| **`lib/search-synonyms.js`** | Typo-tolerant, synonym-aware query matching. Before the catalog is searched, the query is expanded through an operator-curated vocabulary — synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal — and the page shows a "Showing results for" note when the query was corrected or expanded. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. Shared rewrite instance; runs identically at the edge and the container. |
|
|
83
|
+
| **`lib/cookie-consent.js`** | GDPR/ePrivacy cookie consent. Every page carries a banner until the visitor decides — accept all, reject non-essential, or manage categories — with the choice in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies are always on; analytics/marketing are opt-in and default-deny, and a `DNT`/`Sec-GPC` header forces non-essential to denied regardless of stored opt-in. `POST /consent` sets the choice (safe-redirect back); `GET /cookies` is the preferences page (footer-linked). A server-side gating hook renders a category's script only when granted. Banner + form are server-rendered (work with JS off; the island only hides the banner once decided) and identical at the edge and container. |
|
|
83
84
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
|
|
84
85
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
85
86
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.35",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
|
|
6
6
|
"fingerprinted": "css/admin.4dc78dfc45d0736d.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-eNBBXiZ+B0S2drwiWoHzw7FzZRNGRO79o9VIwsaujSHTxQLz2zJabp0x74kFxAxq",
|
|
10
|
+
"fingerprinted": "css/main.0ef22690caec76ea.css"
|
|
11
|
+
},
|
|
12
|
+
"js/consent.js": {
|
|
13
|
+
"integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
|
|
14
|
+
"fingerprinted": "js/consent.517d6ad1fbf106a1.js"
|
|
11
15
|
},
|
|
12
16
|
"js/passkey-login.js": {
|
|
13
17
|
"integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
package/lib/storefront.js
CHANGED
|
@@ -77,6 +77,52 @@ var _render = emailModule._render;
|
|
|
77
77
|
// the bound R2 bucket. The 1536×1024 source PNG is committed
|
|
78
78
|
// only to .template/ (local-only) and uploaded once via
|
|
79
79
|
// `wrangler r2 object put`.
|
|
80
|
+
|
|
81
|
+
// Cookie-consent banner — present in the chrome of every page (both
|
|
82
|
+
// the container render below and each worker/render/*.js LAYOUT, kept
|
|
83
|
+
// byte-identical). GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC
|
|
84
|
+
// art. 5(3)) demand informed, specific, opt-in consent BEFORE any non-
|
|
85
|
+
// strictly-necessary cookie / tracker is set, with default-deny on the
|
|
86
|
+
// toggleable categories and a withdraw path. The banner is a plain
|
|
87
|
+
// server-rendered form that POSTs to /consent — it works with no client
|
|
88
|
+
// JS at all (essential-only browsing, accept, reject, and the granular
|
|
89
|
+
// preference center are all reachable JS-off).
|
|
90
|
+
//
|
|
91
|
+
// Because the storefront's read pages are edge-cached for cookie-less
|
|
92
|
+
// visitors (worker/index.js `_edgeRenderCached`), the banner can't be
|
|
93
|
+
// server-conditionally omitted on a cached page — the cached HTML is one
|
|
94
|
+
// document shared across visitors. So the banner ships in every document
|
|
95
|
+
// and the dismissal is cookie-driven on the client: the consent island
|
|
96
|
+
// (themes/default/assets/js/consent.js) reads the non-sealed
|
|
97
|
+
// `shop_consent_set` flag cookie and hides the banner when a choice
|
|
98
|
+
// already exists. The authoritative decision lives in the sealed
|
|
99
|
+
// `shop_consent` cookie + the cookie-consent ledger (server-side); the
|
|
100
|
+
// flag cookie is a non-authoritative "has decided" hint that only drives
|
|
101
|
+
// banner visibility. Nothing in this block reflects a visitor-supplied
|
|
102
|
+
// value, so there is no interpolation and no escape surface.
|
|
103
|
+
//
|
|
104
|
+
// `hidden` is the default visible state inversion: the dialog renders
|
|
105
|
+
// visible for a visitor with no decision (JS-off included). The island
|
|
106
|
+
// adds `data-consent-decided` to <html> and the CSS hides the dialog,
|
|
107
|
+
// so a returning visitor never sees it. A visitor with JS disabled who
|
|
108
|
+
// has already decided sees the banner again — but every control still
|
|
109
|
+
// works server-side, so they re-confirm rather than hit a dead end.
|
|
110
|
+
var CONSENT_BANNER =
|
|
111
|
+
" <div class=\"consent-banner\" id=\"consent-banner\" role=\"dialog\" aria-modal=\"false\" aria-labelledby=\"consent-title\" aria-describedby=\"consent-desc\">\n" +
|
|
112
|
+
" <div class=\"consent-banner__inner\">\n" +
|
|
113
|
+
" <div class=\"consent-banner__copy\">\n" +
|
|
114
|
+
" <h2 class=\"consent-banner__title\" id=\"consent-title\">Your privacy choices</h2>\n" +
|
|
115
|
+
" <p class=\"consent-banner__desc\" id=\"consent-desc\">We use strictly-necessary cookies to run the shop (your session, security tokens, and this choice itself). Optional cookies — functional, analytics, marketing, and preferences — are off until you turn them on. You can change this any time from <a href=\"/cookies\">Manage cookies</a>.</p>\n" +
|
|
116
|
+
" </div>\n" +
|
|
117
|
+
" <form class=\"consent-banner__actions\" method=\"post\" action=\"/consent\">\n" +
|
|
118
|
+
" <input type=\"hidden\" name=\"return_to\" value=\"/\" data-consent-return>\n" +
|
|
119
|
+
" <button type=\"submit\" name=\"choice\" value=\"accept_all\" class=\"btn-primary consent-banner__btn\">Accept all</button>\n" +
|
|
120
|
+
" <button type=\"submit\" name=\"choice\" value=\"reject\" class=\"btn-ghost consent-banner__btn\">Reject non-essential</button>\n" +
|
|
121
|
+
" <a class=\"consent-banner__manage\" href=\"/cookies\">Manage preferences</a>\n" +
|
|
122
|
+
" </form>\n" +
|
|
123
|
+
" </div>\n" +
|
|
124
|
+
" </div>\n";
|
|
125
|
+
|
|
80
126
|
var LAYOUT =
|
|
81
127
|
"<!DOCTYPE html>\n" +
|
|
82
128
|
"<html lang=\"en\">\n" +
|
|
@@ -194,9 +240,12 @@ var LAYOUT =
|
|
|
194
240
|
" <li><a href=\"/SECURITY.md\">Security</a></li>\n" +
|
|
195
241
|
" <li><a href=\"/privacy\">Privacy</a></li>\n" +
|
|
196
242
|
" <li><a href=\"/terms\">Terms</a></li>\n" +
|
|
243
|
+
" <li><a href=\"/cookies\">Manage cookies</a></li>\n" +
|
|
197
244
|
" </ul>\n" +
|
|
198
245
|
" </div>\n" +
|
|
199
246
|
" </footer>\n" +
|
|
247
|
+
CONSENT_BANNER +
|
|
248
|
+
"RAW_CONSENT_SCRIPT" +
|
|
200
249
|
"</body>\n" +
|
|
201
250
|
"</html>\n";
|
|
202
251
|
|
|
@@ -254,10 +303,16 @@ function _assetSri(relUnderThemeAssets) {
|
|
|
254
303
|
var entry = _assetManifest.assets[relUnderThemeAssets];
|
|
255
304
|
return (entry && entry.integrity) || null;
|
|
256
305
|
}
|
|
257
|
-
function _islandScript(name) {
|
|
306
|
+
function _islandScript(name, opts) {
|
|
258
307
|
var sri = _assetSri("js/" + name);
|
|
259
|
-
|
|
260
|
-
|
|
308
|
+
// Optional `id` (so an island can find its own <script> at runtime) and
|
|
309
|
+
// `policy` (the active consent policy version, stamped for the consent
|
|
310
|
+
// island to compare against the flag cookie). Both are charset-safe by
|
|
311
|
+
// construction at the call site, so they go in without escaping.
|
|
312
|
+
var idAttr = (opts && opts.id) ? " id=\"" + opts.id + "\"" : "";
|
|
313
|
+
var policyAttr = (opts && opts.policy) ? " data-consent-policy=\"" + opts.policy + "\"" : "";
|
|
314
|
+
return "<script" + idAttr + " src=\"" + _assetUrl("js/" + name) + "\"" +
|
|
315
|
+
(sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
|
|
261
316
|
}
|
|
262
317
|
|
|
263
318
|
function _wrap(opts) {
|
|
@@ -291,7 +346,9 @@ function _wrap(opts) {
|
|
|
291
346
|
og_image: ogImage,
|
|
292
347
|
og_url: ogUrl,
|
|
293
348
|
body: "RAW_BODY_PLACEHOLDER",
|
|
294
|
-
}).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
349
|
+
}).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
350
|
+
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
351
|
+
.replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
295
352
|
// The body is RAW HTML (already rendered + escaped at the
|
|
296
353
|
// per-fragment level). The placeholder swap is post-render so the
|
|
297
354
|
// outer renderer's HTML-escape doesn't double-escape the inner
|
|
@@ -3422,6 +3479,82 @@ function renderNewsletterError(opts) {
|
|
|
3422
3479
|
});
|
|
3423
3480
|
}
|
|
3424
3481
|
|
|
3482
|
+
// ---- cookie preference center ------------------------------------------
|
|
3483
|
+
|
|
3484
|
+
// The four toggleable categories + their operator-facing copy, mirroring
|
|
3485
|
+
// lib/cookie-consent.js's category taxonomy. Order is fixed so the page
|
|
3486
|
+
// reads the same every render.
|
|
3487
|
+
var CONSENT_CATEGORY_COPY = [
|
|
3488
|
+
{ key: "functional", name: "Functional", desc: "Remember-me, locale, and currency-selector cookies that make the shop more convenient." },
|
|
3489
|
+
{ key: "analytics", name: "Analytics", desc: "Aggregate, privacy-respecting usage measurement so we can see which pages help and which don't." },
|
|
3490
|
+
{ key: "marketing", name: "Marketing", desc: "Advertising and retargeting pixels. Off unless you turn them on; a Do-Not-Track or Global Privacy Control signal keeps them off regardless." },
|
|
3491
|
+
{ key: "preferences", name: "Preferences", desc: "Your saved UI tweaks — dark mode, list density, and similar non-essential settings." },
|
|
3492
|
+
];
|
|
3493
|
+
|
|
3494
|
+
// The /cookies manage page. Pre-checks each toggle from the visitor's
|
|
3495
|
+
// stored decision (all off when there's no decision yet — default-deny).
|
|
3496
|
+
// `decision` is the shape `_readConsentDecision` returns, or null.
|
|
3497
|
+
// `notice` is an optional operator-fixed confirmation string (e.g. after
|
|
3498
|
+
// a save) — never a reflected visitor value.
|
|
3499
|
+
function renderCookiePreferences(opts) {
|
|
3500
|
+
opts = opts || {};
|
|
3501
|
+
var decision = opts.decision || null;
|
|
3502
|
+
var noticeHtml = "";
|
|
3503
|
+
if (opts.notice === "saved") {
|
|
3504
|
+
noticeHtml = "<p class=\"consent-page__notice\" role=\"status\">Your cookie preferences were saved. They take effect immediately and you can change them again any time on this page.</p>";
|
|
3505
|
+
} else if (opts.notice === "invalid") {
|
|
3506
|
+
noticeHtml = "<p class=\"consent-page__notice\" role=\"alert\">That submission wasn't understood, so nothing changed. Choose your categories below and save again.</p>";
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
var cats = "";
|
|
3510
|
+
for (var i = 0; i < CONSENT_CATEGORY_COPY.length; i += 1) {
|
|
3511
|
+
var c = CONSENT_CATEGORY_COPY[i];
|
|
3512
|
+
var on = decision && decision.categories && decision.categories[c.key] === true;
|
|
3513
|
+
cats +=
|
|
3514
|
+
"<div class=\"consent-cat\">" +
|
|
3515
|
+
"<div class=\"consent-cat__head\">" +
|
|
3516
|
+
"<h2 class=\"consent-cat__name\">" + c.name + "</h2>" +
|
|
3517
|
+
"<label class=\"consent-toggle\">" +
|
|
3518
|
+
"<span class=\"skip-link\">Allow " + c.name + " cookies</span>" +
|
|
3519
|
+
"<input type=\"checkbox\" name=\"cat_" + c.key + "\" value=\"1\"" + (on ? " checked" : "") + ">" +
|
|
3520
|
+
"</label>" +
|
|
3521
|
+
"</div>" +
|
|
3522
|
+
"<p class=\"consent-cat__desc\">" + c.desc + "</p>" +
|
|
3523
|
+
"</div>";
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
var body =
|
|
3527
|
+
"<section class=\"consent-page\">" +
|
|
3528
|
+
"<p class=\"eyebrow\">Privacy</p>" +
|
|
3529
|
+
"<h1>Cookie preferences</h1>" +
|
|
3530
|
+
"<p class=\"consent-page__lede\">Strictly-necessary cookies — your session, security tokens, and this choice itself — are always on because the shop can't run without them. Everything below is optional and off by default.</p>" +
|
|
3531
|
+
noticeHtml +
|
|
3532
|
+
"<form method=\"post\" action=\"/consent\">" +
|
|
3533
|
+
"<input type=\"hidden\" name=\"return_to\" value=\"/cookies\">" +
|
|
3534
|
+
"<div class=\"consent-cat\">" +
|
|
3535
|
+
"<div class=\"consent-cat__head\">" +
|
|
3536
|
+
"<h2 class=\"consent-cat__name\">Strictly necessary</h2>" +
|
|
3537
|
+
"<span class=\"consent-cat__always\">Always on</span>" +
|
|
3538
|
+
"</div>" +
|
|
3539
|
+
"<p class=\"consent-cat__desc\">Session, CSRF protection, and your cookie choice. These can't be switched off.</p>" +
|
|
3540
|
+
"</div>" +
|
|
3541
|
+
cats +
|
|
3542
|
+
"<div class=\"consent-page__actions\">" +
|
|
3543
|
+
"<button type=\"submit\" name=\"choice\" value=\"granular\" class=\"btn-primary\">Save preferences</button>" +
|
|
3544
|
+
"<button type=\"submit\" name=\"choice\" value=\"accept_all\" class=\"btn-ghost\">Accept all</button>" +
|
|
3545
|
+
"<button type=\"submit\" name=\"choice\" value=\"reject\" class=\"btn-ghost\">Reject non-essential</button>" +
|
|
3546
|
+
"</div>" +
|
|
3547
|
+
"</form>" +
|
|
3548
|
+
"</section>";
|
|
3549
|
+
return _wrap({
|
|
3550
|
+
title: "Cookie preferences",
|
|
3551
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3552
|
+
cart_count: opts.cart_count,
|
|
3553
|
+
theme_css: opts.theme_css,
|
|
3554
|
+
body: body,
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3425
3558
|
// ---- 404 ---------------------------------------------------------------
|
|
3426
3559
|
|
|
3427
3560
|
function renderNotFound(opts) {
|
|
@@ -3589,6 +3722,148 @@ function _readReferralEnv(req) {
|
|
|
3589
3722
|
try { return JSON.parse(raw); } catch (_e) { return null; }
|
|
3590
3723
|
}
|
|
3591
3724
|
|
|
3725
|
+
// ---- cookie-consent cookies --------------------------------------------
|
|
3726
|
+
//
|
|
3727
|
+
// Two cookies carry a visitor's cookie-consent decision:
|
|
3728
|
+
//
|
|
3729
|
+
// * `shop_consent` — the authoritative decision, vault-sealed +
|
|
3730
|
+
// HttpOnly. Holds { categories, policy_version,
|
|
3731
|
+
// ts }. Read server-side to gate non-essential
|
|
3732
|
+
// cookies/trackers and to pre-check the manage
|
|
3733
|
+
// page's toggles. A tampered / truncated / stale
|
|
3734
|
+
// value fails the seal and reads as "no decision"
|
|
3735
|
+
// (the banner reshows) rather than throwing.
|
|
3736
|
+
// * `shop_consent_set` — a non-sealed, NON-HttpOnly "1" flag the consent
|
|
3737
|
+
// island reads to hide the banner on edge-cached
|
|
3738
|
+
// pages. Non-authoritative: it only drives banner
|
|
3739
|
+
// visibility, never the actual cookie/tracker gate.
|
|
3740
|
+
//
|
|
3741
|
+
// The four toggleable categories mirror lib/cookie-consent.js's
|
|
3742
|
+
// TOGGLEABLE_CATEGORIES; strictly-necessary is implicit-on and never
|
|
3743
|
+
// stored here.
|
|
3744
|
+
var CONSENT_COOKIE_NAME = "shop_consent";
|
|
3745
|
+
var CONSENT_FLAG_COOKIE_NAME = "shop_consent_set";
|
|
3746
|
+
var CONSENT_TOGGLEABLE = ["functional", "analytics", "marketing", "preferences"];
|
|
3747
|
+
|
|
3748
|
+
// The consent policy version visitors are being asked to consent to. A
|
|
3749
|
+
// stored decision (sealed cookie) captured under an older version is no
|
|
3750
|
+
// longer authoritative: the gate stops honoring it and the banner re-
|
|
3751
|
+
// prompts, so an operator who materially changes their cookie policy and
|
|
3752
|
+
// bumps the version re-collects consent rather than coasting on stale
|
|
3753
|
+
// opt-ins. Mirrors lib/cookie-consent.js's `policy_version` charset so the
|
|
3754
|
+
// value is safe to stamp into a cookie value and an HTML attribute; an
|
|
3755
|
+
// out-of-charset / over-length / missing value falls back to "v1" (the
|
|
3756
|
+
// initial version, matching the edge worker's stamped default). The active
|
|
3757
|
+
// value is read live from `deps.cookieConsent.policyVersion` per request in
|
|
3758
|
+
// the server gate (so a runtime bump takes effect immediately); the module
|
|
3759
|
+
// snapshot drives the page-stamped value set once at mount.
|
|
3760
|
+
var _activeConsentPolicy = "v1";
|
|
3761
|
+
function _sanitizeConsentPolicy(v) {
|
|
3762
|
+
return (typeof v === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(v)) ? v : "v1";
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
// Persist a decision: the sealed authoritative cookie + the non-sealed
|
|
3766
|
+
// flag. Both expire in 180 days — ICO / CNIL guidance treats ~6 months as
|
|
3767
|
+
// the upper bound before a consent re-prompt; the cookie-consent ledger
|
|
3768
|
+
// keeps the durable audit record regardless of cookie lifetime.
|
|
3769
|
+
function _setConsentCookies(res, decision) {
|
|
3770
|
+
var T = b.constants.TIME;
|
|
3771
|
+
var exp = new Date(Date.now() + T.days(180));
|
|
3772
|
+
_cookieJar().writeSealed(res, CONSENT_COOKIE_NAME, JSON.stringify(decision), { expires: exp });
|
|
3773
|
+
// Not HttpOnly — the consent island must read it from document.cookie to
|
|
3774
|
+
// decide whether to hide the banner. Its value is the policy version the
|
|
3775
|
+
// decision was captured under (charset-constrained, no decision detail),
|
|
3776
|
+
// so the island can compare it to the active version stamped on its
|
|
3777
|
+
// <script> tag and re-prompt when the operator has bumped the policy. A
|
|
3778
|
+
// script-readable version string leaks nothing the server would act on.
|
|
3779
|
+
var flagVal = _sanitizeConsentPolicy(decision && decision.policy_version);
|
|
3780
|
+
_cookieJar().write(res, CONSENT_FLAG_COOKIE_NAME, flagVal, { expires: exp, httpOnly: false });
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
// The visitor's stored decision, or null when none / malformed / stale.
|
|
3784
|
+
// Shape-validates the unsealed payload so a forged-but-unsealable or schema-
|
|
3785
|
+
// drifted value reads as "no decision". `activePolicy` is the policy version
|
|
3786
|
+
// currently in force (defaults to the module snapshot; the server routes
|
|
3787
|
+
// pass the live `deps.cookieConsent.policyVersion`): a decision whose
|
|
3788
|
+
// `policy_version` doesn't match it — including an unversioned legacy value
|
|
3789
|
+
// — reads as "no decision" so the operator's policy bump re-collects consent
|
|
3790
|
+
// rather than the gate honoring opt-ins captured under a superseded policy.
|
|
3791
|
+
function _readConsentDecision(req, activePolicy) {
|
|
3792
|
+
var raw = _cookieJar().readSealed(req, CONSENT_COOKIE_NAME);
|
|
3793
|
+
if (raw === null) return null;
|
|
3794
|
+
var parsed;
|
|
3795
|
+
try { parsed = JSON.parse(raw); } catch (_e) { return null; }
|
|
3796
|
+
if (!parsed || typeof parsed !== "object" || !parsed.categories || typeof parsed.categories !== "object") {
|
|
3797
|
+
return null;
|
|
3798
|
+
}
|
|
3799
|
+
var active = (arguments.length > 1) ? _sanitizeConsentPolicy(activePolicy) : _activeConsentPolicy;
|
|
3800
|
+
if (parsed.policy_version !== active) return null;
|
|
3801
|
+
var cats = {};
|
|
3802
|
+
for (var i = 0; i < CONSENT_TOGGLEABLE.length; i += 1) {
|
|
3803
|
+
cats[CONSENT_TOGGLEABLE[i]] = parsed.categories[CONSENT_TOGGLEABLE[i]] === true;
|
|
3804
|
+
}
|
|
3805
|
+
return {
|
|
3806
|
+
categories: cats,
|
|
3807
|
+
policy_version: parsed.policy_version,
|
|
3808
|
+
ts: Number.isFinite(parsed.ts) ? parsed.ts : null,
|
|
3809
|
+
};
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
// Server-side gating hook. Returns true when `category` may emit a
|
|
3813
|
+
// cookie / tag / pixel byte for this request. Strictly-necessary is
|
|
3814
|
+
// always allowed; the four toggleable categories consult the stored
|
|
3815
|
+
// decision (default-deny when absent). DNT / Sec-GPC collapse analytics
|
|
3816
|
+
// + marketing to false regardless of the stored opt-in (browser-level
|
|
3817
|
+
// opt-out wins — same rule the cookie-consent ledger records and
|
|
3818
|
+
// honors). This is the single function a future analytics / marketing
|
|
3819
|
+
// island gates its render on:
|
|
3820
|
+
//
|
|
3821
|
+
// if (_consentAllows(req, "analytics", _liveConsentPolicy())) body += _islandScript("analytics.js");
|
|
3822
|
+
//
|
|
3823
|
+
// so a tracker is never injected into the document unless the visitor
|
|
3824
|
+
// opted that category in.
|
|
3825
|
+
function _consentAllows(req, category, activePolicy) {
|
|
3826
|
+
if (category === "strictly_necessary") return true;
|
|
3827
|
+
if (CONSENT_TOGGLEABLE.indexOf(category) === -1) return false;
|
|
3828
|
+
var decision = (arguments.length > 2)
|
|
3829
|
+
? _readConsentDecision(req, activePolicy)
|
|
3830
|
+
: _readConsentDecision(req);
|
|
3831
|
+
if (!decision) return false;
|
|
3832
|
+
if ((category === "analytics" || category === "marketing") && _browserOptOut(req)) return false;
|
|
3833
|
+
return decision.categories[category] === true;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
// DNT (Do-Not-Track) header set to "1". Defensive read — missing /
|
|
3837
|
+
// garbage reads as "no signal".
|
|
3838
|
+
function _dntSignal(req) {
|
|
3839
|
+
var h = (req && req.headers) || {};
|
|
3840
|
+
return String(h["dnt"] || h["DNT"] || "") === "1";
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Sec-GPC (Global Privacy Control) header set to "1".
|
|
3844
|
+
function _gpcSignal(req) {
|
|
3845
|
+
var h = (req && req.headers) || {};
|
|
3846
|
+
return String(h["sec-gpc"] || h["Sec-GPC"] || "") === "1";
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
// Either DNT or GPC is an implicit deny for analytics + marketing.
|
|
3850
|
+
function _browserOptOut(req) {
|
|
3851
|
+
return _dntSignal(req) || _gpcSignal(req);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
// Coarse UA classifier for the consent ledger row — matches
|
|
3855
|
+
// lib/cookie-consent.js's UA_CLASS_VALUES. Defensive: unknown / missing
|
|
3856
|
+
// UA reads as "unknown".
|
|
3857
|
+
function _uaClass(req) {
|
|
3858
|
+
var ua = String((req && req.headers && (req.headers["user-agent"] || req.headers["User-Agent"])) || "").toLowerCase();
|
|
3859
|
+
if (!ua) return "unknown";
|
|
3860
|
+
if (/bot|crawl|spider|slurp|bingpreview|headless/.test(ua)) return "bot";
|
|
3861
|
+
if (/ipad|tablet|kindle|playbook|silk/.test(ua)) return "tablet";
|
|
3862
|
+
if (/mobi|iphone|android.*mobile|phone/.test(ua)) return "mobile";
|
|
3863
|
+
if (/windows|macintosh|linux|cros|x11/.test(ua)) return "desktop";
|
|
3864
|
+
return "unknown";
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3592
3867
|
// ---- account-page renderers --------------------------------------------
|
|
3593
3868
|
|
|
3594
3869
|
var ACCOUNT_LOGIN_PAGE =
|
|
@@ -3808,6 +4083,20 @@ function mount(router, deps) {
|
|
|
3808
4083
|
// Stripe via the primitive); without it the list stays read-only.
|
|
3809
4084
|
var subscriptions = deps.subscriptions || null;
|
|
3810
4085
|
|
|
4086
|
+
// Active cookie-consent policy version. `_liveConsentPolicy()` reads it
|
|
4087
|
+
// from the consent primitive per request so a runtime `policyVersion`
|
|
4088
|
+
// bump takes effect on the gate immediately, and refreshes the module
|
|
4089
|
+
// snapshot the page-stamp reads — the snapshot exists because the
|
|
4090
|
+
// module-level renderers (`_wrap`) don't close over `deps`, so consulting
|
|
4091
|
+
// the live version (every consent route does) keeps the stamped value in
|
|
4092
|
+
// step without threading it through every render call. Set once here so
|
|
4093
|
+
// the value is correct from the first render after boot.
|
|
4094
|
+
function _liveConsentPolicy() {
|
|
4095
|
+
_activeConsentPolicy = _sanitizeConsentPolicy(deps.cookieConsent && deps.cookieConsent.policyVersion);
|
|
4096
|
+
return _activeConsentPolicy;
|
|
4097
|
+
}
|
|
4098
|
+
_liveConsentPolicy();
|
|
4099
|
+
|
|
3811
4100
|
function _send(res, status, html) {
|
|
3812
4101
|
res.status(status);
|
|
3813
4102
|
res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
|
|
@@ -6966,6 +7255,144 @@ function mount(router, deps) {
|
|
|
6966
7255
|
});
|
|
6967
7256
|
}
|
|
6968
7257
|
|
|
7258
|
+
// ---- cookie consent -----------------------------------------------------
|
|
7259
|
+
//
|
|
7260
|
+
// GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC art. 5(3)) opt-in
|
|
7261
|
+
// for non-strictly-necessary cookies. The banner ships in the chrome of
|
|
7262
|
+
// every page (see CONSENT_BANNER in the layout); these two routes back
|
|
7263
|
+
// it. No auth, no client JS required — the banner form and the manage
|
|
7264
|
+
// page both work server-rendered for a guest with scripting disabled.
|
|
7265
|
+
//
|
|
7266
|
+
// The decision is written to a sealed first-party cookie (the gate) AND
|
|
7267
|
+
// recorded in the cookie-consent ledger (the audit trail) when the
|
|
7268
|
+
// primitive is wired. CSRF / origin / fetch-metadata defenses are the
|
|
7269
|
+
// framework middleware already on every POST — no per-route re-check.
|
|
7270
|
+
|
|
7271
|
+
// Same-origin path guard for `return_to`. Accepts a single leading slash
|
|
7272
|
+
// followed by a non-slash (so `//evil.example` and absolute URLs are
|
|
7273
|
+
// refused), capping length defensively. Anything else collapses to the
|
|
7274
|
+
// safe default the caller passes.
|
|
7275
|
+
function _consentReturnTo(raw, fallback) {
|
|
7276
|
+
if (typeof raw === "string" && raw.length <= 512 && /^\/[^/]/.test(raw)) return raw;
|
|
7277
|
+
return fallback;
|
|
7278
|
+
}
|
|
7279
|
+
|
|
7280
|
+
// Translate a posted choice + per-category checkboxes into the four
|
|
7281
|
+
// toggleable booleans. `accept_all` turns every category on; `reject`
|
|
7282
|
+
// turns every category off; `granular` reads each `cat_<key>` checkbox
|
|
7283
|
+
// (present + "1" == on, absent == off — the unchecked-by-default
|
|
7284
|
+
// ePrivacy shape). Returns null for an unknown choice so the caller can
|
|
7285
|
+
// 400 a malformed submit.
|
|
7286
|
+
function _consentCategoriesFromBody(body) {
|
|
7287
|
+
var choice = body && body.choice;
|
|
7288
|
+
var cats = { functional: false, analytics: false, marketing: false, preferences: false };
|
|
7289
|
+
if (choice === "accept_all") {
|
|
7290
|
+
cats.functional = cats.analytics = cats.marketing = cats.preferences = true;
|
|
7291
|
+
return cats;
|
|
7292
|
+
}
|
|
7293
|
+
if (choice === "reject") {
|
|
7294
|
+
return cats;
|
|
7295
|
+
}
|
|
7296
|
+
if (choice === "granular") {
|
|
7297
|
+
for (var i = 0; i < CONSENT_TOGGLEABLE.length; i += 1) {
|
|
7298
|
+
var k = CONSENT_TOGGLEABLE[i];
|
|
7299
|
+
cats[k] = body["cat_" + k] === "1";
|
|
7300
|
+
}
|
|
7301
|
+
return cats;
|
|
7302
|
+
}
|
|
7303
|
+
return null;
|
|
7304
|
+
}
|
|
7305
|
+
|
|
7306
|
+
// Ensure a session id exists so the consent decision is keyed to a
|
|
7307
|
+
// stable (hashed-at-the-ledger) session. Reuses the existing shop_sid
|
|
7308
|
+
// cookie when present; mints one otherwise — consent is a guest-
|
|
7309
|
+
// reachable flow, so it can't assume a cart already created the sid.
|
|
7310
|
+
function _ensureSid(req, res) {
|
|
7311
|
+
var sid = _readSidCookie(req);
|
|
7312
|
+
if (!sid) {
|
|
7313
|
+
sid = b.uuid.v7();
|
|
7314
|
+
_setSidCookie(res, sid);
|
|
7315
|
+
}
|
|
7316
|
+
return sid;
|
|
7317
|
+
}
|
|
7318
|
+
|
|
7319
|
+
// POST /consent — set the decision. Drives both the banner (Accept all /
|
|
7320
|
+
// Reject) and the manage page's granular save. Writes the sealed gate
|
|
7321
|
+
// cookie + the non-sealed flag cookie, records the decision in the
|
|
7322
|
+
// cookie-consent ledger (best-effort — a ledger hiccup never blocks the
|
|
7323
|
+
// decision from taking effect), then 303s back to a safe same-origin
|
|
7324
|
+
// return_to.
|
|
7325
|
+
router.post("/consent", async function (req, res) {
|
|
7326
|
+
var body = req.body || {};
|
|
7327
|
+
var cats = _consentCategoriesFromBody(body);
|
|
7328
|
+
var fromManage = (typeof body.return_to === "string" && body.return_to.indexOf("/cookies") === 0);
|
|
7329
|
+
|
|
7330
|
+
// Malformed (unknown / missing choice) → 400. Re-render the manage
|
|
7331
|
+
// page so the visitor lands somewhere actionable rather than on a
|
|
7332
|
+
// bare error string.
|
|
7333
|
+
if (!cats) {
|
|
7334
|
+
var cartCount400 = 0;
|
|
7335
|
+
try { cartCount400 = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
7336
|
+
return _send(res, 400, renderCookiePreferences({
|
|
7337
|
+
shop_name: shopName,
|
|
7338
|
+
cart_count: cartCount400,
|
|
7339
|
+
theme: theme,
|
|
7340
|
+
decision: _readConsentDecision(req, _liveConsentPolicy()),
|
|
7341
|
+
notice: "invalid",
|
|
7342
|
+
}));
|
|
7343
|
+
}
|
|
7344
|
+
|
|
7345
|
+
var sid = _ensureSid(req, res);
|
|
7346
|
+
var decision = {
|
|
7347
|
+
categories: cats,
|
|
7348
|
+
policy_version: _liveConsentPolicy(),
|
|
7349
|
+
ts: Date.now(),
|
|
7350
|
+
};
|
|
7351
|
+
_setConsentCookies(res, decision);
|
|
7352
|
+
|
|
7353
|
+
// Durable audit trail. The cookie-consent ledger hashes the session id
|
|
7354
|
+
// itself; we pass the raw sid + the browser DNT / GPC signals + a
|
|
7355
|
+
// coarse UA class so the operator can prove to a supervisory authority
|
|
7356
|
+
// both what was chosen and that a browser-level opt-out was honored.
|
|
7357
|
+
if (deps.cookieConsent) {
|
|
7358
|
+
try {
|
|
7359
|
+
await deps.cookieConsent.recordConsent({
|
|
7360
|
+
session_id: sid,
|
|
7361
|
+
categories: cats,
|
|
7362
|
+
ua_class: _uaClass(req),
|
|
7363
|
+
dnt: _dntSignal(req),
|
|
7364
|
+
gpc: _gpcSignal(req),
|
|
7365
|
+
});
|
|
7366
|
+
} catch (_e) { /* drop-silent — the gate cookie is authoritative; the ledger write is the audit trail and must not block the decision */ }
|
|
7367
|
+
}
|
|
7368
|
+
|
|
7369
|
+
var dest = _consentReturnTo(body.return_to, "/");
|
|
7370
|
+
res.status(303);
|
|
7371
|
+
res.setHeader && res.setHeader("location", fromManage ? "/cookies?saved=1" : dest);
|
|
7372
|
+
return res.end ? res.end() : res.send("");
|
|
7373
|
+
});
|
|
7374
|
+
|
|
7375
|
+
// GET /cookies — the preference center. Linked from the footer's
|
|
7376
|
+
// "Manage cookies" and the banner's "Manage preferences". Pre-checks
|
|
7377
|
+
// each toggle from the stored decision (all off when none exists). The
|
|
7378
|
+
// `?saved=1` query renders a confirmation notice after a save 303.
|
|
7379
|
+
router.get("/cookies", async function (req, res) {
|
|
7380
|
+
var cartCount = 0;
|
|
7381
|
+
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
7382
|
+
var saved = false;
|
|
7383
|
+
try {
|
|
7384
|
+
var u = new URL(req.url, "http://localhost");
|
|
7385
|
+
saved = u.searchParams.get("saved") === "1";
|
|
7386
|
+
} catch (_e) { saved = false; }
|
|
7387
|
+
return _send(res, 200, renderCookiePreferences({
|
|
7388
|
+
shop_name: shopName,
|
|
7389
|
+
cart_count: cartCount,
|
|
7390
|
+
theme: theme,
|
|
7391
|
+
decision: _readConsentDecision(req, _liveConsentPolicy()),
|
|
7392
|
+
notice: saved ? "saved" : null,
|
|
7393
|
+
}));
|
|
7394
|
+
});
|
|
7395
|
+
|
|
6969
7396
|
// robots.txt — minimal crawl policy. Allow everything except
|
|
6970
7397
|
// the admin API + cart + account + checkout / pay / orders (these
|
|
6971
7398
|
// are session-scoped or operator-only, no crawl value), and
|
|
@@ -7083,6 +7510,7 @@ module.exports = {
|
|
|
7083
7510
|
renderAccountRegister: renderAccountRegister,
|
|
7084
7511
|
renderAccount: renderAccount,
|
|
7085
7512
|
renderAccountSubscriptions: renderAccountSubscriptions,
|
|
7513
|
+
renderCookiePreferences: renderCookiePreferences,
|
|
7086
7514
|
renderNotFound: renderNotFound,
|
|
7087
7515
|
// Layout exposed so operators forking the framework can override.
|
|
7088
7516
|
_wrap: _wrap,
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.4",
|
|
7
|
+
"tag": "v0.13.4",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"server": "lib/vendor/blamejs/"
|
|
14
14
|
},
|
|
15
15
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
16
|
-
"bundledAt": "2026-05-
|
|
16
|
+
"bundledAt": "2026-05-27"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.4 (2026-05-26) — **`b.crdt` — conflict-free replicated data types.** b.crdt adds state-based Conflict-free Replicated Data Types: data structures that independent replicas update without coordination and still converge to the same value once they have exchanged state. Each type's merge is a join over a semilattice — commutative, associative, and idempotent — so replicas can merge in any order, any number of times, and agree, which makes these the substrate for active/active cluster state, offline-first clients that reconcile on reconnect, and eventually-consistent counters, sets, and maps. The release ships the full state-based family: grow-only and positive-negative counters (gCounter / pnCounter), grow-only, two-phase, and observed-remove sets (gSet / twoPSet / orSet), a last-write-wins register (lwwRegister), and an observed-remove map (orMap). Every type exposes the same contract — local mutators, merge(other) that returns a converged instance without mutating either operand, value() for the materialized value, and state() / fromState() for a JSON-serializable form to snapshot via b.archive or b.backup or ship to a peer — and carries a replicaId so per-replica contributions stay distinct. **Added:** *`b.crdt` — state-based CvRDT counters, sets, register, and map* — `b.crdt.gCounter` / `pnCounter` (grow-only and increment/decrement counters), `b.crdt.gSet` / `twoPSet` / `orSet` (grow-only, two-phase, and observed-remove sets — `orSet` supports re-add and resolves a concurrent add-vs-remove as add-wins), `b.crdt.lwwRegister` (last-write-wins with a deterministic replicaId tie-break), and `b.crdt.orMap` (observed-remove keys with last-write-wins values). Each exposes `merge` / `value` / `state` / `fromState` and converges by the CvRDT laws. `orSet` and `orMap` accept `tombstoneRetention` to bound tombstone memory against a remove flood.
|
|
12
|
+
|
|
13
|
+
- v0.13.3 (2026-05-26) — **`b.crypto.xwing` — X-Wing hybrid post-quantum KEM.** b.crypto.xwing adds the X-Wing hybrid key-encapsulation mechanism (draft-connolly-cfrg-xwing-kem): it runs ML-KEM-768 and X25519 side by side and binds their shared secrets with SHA3-256, so an encapsulated key stays secure as long as either ML-KEM-768 or X25519 holds. That is the conservative shape for moving off classical ECDH today — a harvest-now-decrypt-later attacker must break the lattice KEM, and a hypothetical ML-KEM break still leaves X25519 standing. keygen() produces a 32-byte decapsulation seed and a 1216-byte public key; encapsulate(publicKey) returns a 1120-byte ciphertext and a 32-byte shared secret; decapsulate(secretKey, ciphertext) recovers it. The X-Wing combiner is frozen, but its specification is still an IETF Internet-Draft, so this primitive is marked experimental and sits beside the existing pre-RFC post-quantum HPKE drafts; it composes the framework's vendored ML-KEM-768 and X25519 with SHA3 and adds no new cryptographic core. The combiner is known-answer-tested byte-for-byte against the draft's definition. **Added:** *`b.crypto.xwing` — X-Wing hybrid PQ/T KEM (experimental)* — `keygen(seed?)` → `{ publicKey (1216 B), secretKey (32-byte seed) }`; `encapsulate(publicKey, eseed?)` → `{ ciphertext (1120 B), sharedSecret (32 B) }`; `decapsulate(secretKey, ciphertext)` → the 32-byte shared secret. Both `keygen` and `encapsulate` accept an optional seed for deterministic operation. The combiner — `SHA3-256(ssMLKEM ‖ ssX25519 ‖ ctX25519 ‖ pkX25519 ‖ label)` — is exposed as `combiner` for advanced use. Marked `experimental` while draft-connolly-cfrg-xwing-kem remains an Internet-Draft; the algorithm itself is frozen.
|
|
14
|
+
|
|
15
|
+
- v0.13.2 (2026-05-26) — **`b.iabTcf.encode` — write TCF consent strings, and a TC-string timestamp fix.** b.iabTcf gains the encode half of its consent-string codec: b.iabTcf.encode(obj) serialises a parsed object back into an IAB TCF v2 TC string, and b.iabTcf.isValid(tcString) is a total never-throwing validity check. Vendor and purpose collections may be Sets, id arrays, or the parsed sections parseString returns; vendor sections are written with whichever of the bit-field and range forms is smaller, matching the reference CMP encoders, so a parsed string round-trips to an equivalent signal. parseString now fully decodes the Core publisher-restrictions list and the PublisherTC segment's publisher and custom purposes, where it previously reported only the segment's presence. The encoder is verified against the worked-example string in the IAB Tech Lab consent-string specification: it re-encodes that string's Core segment byte-for-byte. This release also fixes a TC-string parsing bug — the bit reader accumulated values with a 32-bit shift, so the 36-bit Created and LastUpdated timestamp fields were silently truncated for any real date; they now decode and round-trip exactly. **Added:** *`b.iabTcf.encode` / `b.iabTcf.isValid`* — `encode(obj)` serialises a TCF object (the shape `parseString` returns) into a TC string — Core plus optional DisclosedVendors, AllowedVendors, and PublisherTC segments — choosing the smaller of the bit-field and range vendor encodings. `isValid(tcString)` returns whether a string parses as a well-formed Core segment without throwing. `parseString` now fully decodes Core publisher restrictions and the PublisherTC purposes that were previously reported only as present. **Fixed:** *TC-string 36-bit timestamps were truncated on parse* — `b.iabTcf.parseString` read multi-bit fields with a 32-bit left-shift accumulation. The 36-bit Created and LastUpdated fields hold deciseconds-since-epoch, which exceeds 2^31 for any date after 1976, so those timestamps were silently corrupted. The reader now accumulates without the 32-bit truncation; timestamps decode correctly and round-trip through `encode`.
|
|
16
|
+
|
|
11
17
|
- v0.13.1 (2026-05-26) — **`b.worm` — write-once-read-many retention.** Store records that cannot be altered or deleted before a retention period elapses — the immutable-storage discipline regulators require (SEC 17a-4(f), CFTC 1.31, FINRA 4511). b.worm.create(opts) returns a WORM store that enforces, on every mutating call, that a record is not overwritten or deleted while it is within its retainUntil window or under a legal hold. Two modes mirror cloud Object-Lock: compliance (the default — no one, including the operator, can delete before expiry) and governance (a privileged caller may override with an audited reason). Retention can only be extended, never shortened; every record carries a SHA3-512 digest that get verifies, so tampering with the underlying bytes is detected on read; every allow/refuse decision is audited. Storage is pluggable via a synchronous store adapter, so the policy layer sits over a sealed DB table, a filesystem, or any non-S3 backend — the store-agnostic, application-level companion to b.objectStore's S3 Object Lock, with content-integrity verification that native Object Lock does not provide. **Added:** *`b.worm.create` — write-once-read-many retention* — Returns a store with `put` / `get` / `delete` / `extendRetention` / `placeLegalHold` / `releaseLegalHold` / `list`. `put` is write-once (an overwrite of a retained or held record is refused); `delete` is gated by the retention window, legal holds, and the mode (`compliance` refuses any early delete; `governance` allows a privileged override with a required, audited reason); `extendRetention` is extend-only; `get` verifies the stored SHA3-512 digest and throws `worm/tampered` on a mismatch. Storage is a pluggable synchronous adapter (`get` / `set` / `delete` / `has` / `keys`), defaulting to in-memory for tests. Use it for SEC 17a-4 / CFTC / FINRA immutable records on backends without native Object Lock; `b.objectStore` remains the path for S3 Object Lock.
|
|
12
18
|
|
|
13
19
|
- v0.13.0 (2026-05-26) — **`b.crypto.oprf` — RFC 9497 Oblivious PRFs.** Compute F(serverKey, input) without the server learning the input and without the client learning the key — the Oblivious PRF primitive behind password hardening (the server peppers a password it never sees), private set intersection, and Privacy Pass. b.crypto.oprf.suite(name) returns an RFC 9497 ciphersuite — ristretto255-sha512, p256-sha256, p384-sha384, or p521-sha512 — each exposing the base oprf mode and the verifiable voprf mode (a DLEQ proof lets the client confirm the server used the key committed in its public key). The client blinds its input, the server blind-evaluates with its secret key, and the client finalizes by un-blinding and hashing; because un-blinding cancels the blind, the output depends only on key and input. Validated byte-for-byte against the RFC 9497 Appendix-A test vectors. Group and hash-to-curve operations come from the newly vendored @noble/curves (Paul Miller, MIT) — the same maintainer as the framework's existing vendored @noble/post-quantum and @noble/ciphers, with no added npm runtime dependency. **Added:** *`b.crypto.oprf` — RFC 9497 OPRF / VOPRF* — `suite(name)` returns `{ name, oprf, voprf }` for one of the four RFC 9497 ciphersuites (ristretto255-SHA512 / P-256-SHA256 / P-384-SHA384 / P-521-SHA512). The `oprf` (base) mode provides `deriveKeyPair` / `generateKeyPair` / `blind` / `blindEvaluate` / `finalize` / `evaluate`; `voprf` (verifiable) adds a DLEQ proof so the client can prove the server used the committed key. Use it for password hardening, private set intersection, and OPRF-based tokens. Verified against the RFC 9497 Appendix-A vectors. The partially-oblivious `poprf` mode is not yet exposed (the vendored `@noble/curves` does not implement it) and will follow upstream. · *Vendored `@noble/curves`* — `@noble/curves` 2.2.0 (Paul Miller, MIT) is vendored under `lib/vendor/` (no npm runtime dependency), supplying the ristretto255 / NIST-curve group and hash-to-curve operations behind `b.crypto.oprf`. It joins the existing vendored `@noble/post-quantum` and `@noble/ciphers` from the same maintainer; tracked in the SBOM and the vendor-currency gate.
|