@blamejs/blamejs-shop 0.1.34 → 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 CHANGED
@@ -8,6 +8,8 @@ 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
+
11
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.
12
14
 
13
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.
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. |
@@ -1,13 +1,17 @@
1
1
  {
2
- "version": "0.1.34",
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-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
10
- "fingerprinted": "css/main.91c0dfab0198569c.css"
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
- return "<script src=\"" + _assetUrl("js/" + name) + "\"" +
260
- (sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
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).replace("RAW_BODY_PLACEHOLDER", opts.body);
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,
@@ -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.2",
7
- "tag": "v0.13.2",
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-26"
16
+ "bundledAt": "2026-05-27"
17
17
  }
18
18
  }
19
19
  }
@@ -8,6 +8,10 @@ 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
+
11
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`.
12
16
 
13
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.
@@ -101,6 +101,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
101
101
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
102
102
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
103
103
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
104
+ - **X-Wing hybrid KEM** — `b.crypto.xwing` (draft-connolly-cfrg-xwing-kem, experimental): ML-KEM-768 + X25519 bound by SHA3-256, secure if either component holds — the conservative key-encapsulation shape for migrating off classical ECDH. `keygen` / `encapsulate` / `decapsulate` with a 1216-byte public key, 1120-byte ciphertext, and 32-byte shared secret
104
105
  - **Link header** — RFC 8288 Web Linking codec (`b.linkHeader.parse` / `serialize`): parse and build `Link: <uri>; rel="next"` relations, the standard REST pagination mechanism; quote-aware (a comma inside a quoted parameter never splits the list)
105
106
  - **URI Templates** — RFC 6570 expansion (`b.uriTemplate.expand` / `compile`): full Level 4 — every operator, the `:N` prefix and `*` explode modifiers — turning `{/path}{?q*}` plus variables into a concrete URI; validated against the official uritemplate-test suite. The `{var}` syntax behind OpenAPI links and HAL `_links`
106
107
  - **JSON Type Definition** — RFC 8927 validation (`b.jtd.validate` / `isValid`): portable, cross-implementation schema validation (all eight forms — type / enum / elements / properties / values / discriminator / ref / empty), returning instancePath / schemaPath errors; validated against the official 316-case suite. Interop companion to the fluent `b.safeSchema` builder
@@ -231,6 +232,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
231
232
  ### Production
232
233
 
233
234
  - **Cluster + scheduling** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`)
235
+ - **CRDTs** — state-based conflict-free replicated data types (`b.crdt`): grow-only / PN counters, grow-only / two-phase / observed-remove sets, a last-write-wins register, and an observed-remove map; each `merge` is commutative / associative / idempotent so replicas converge with no coordination — the substrate for active/active and offline-first state, with `state()` / `fromState()` for snapshot via `b.archive` / `b.backup`
234
236
  - **Reliability** — retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`)
235
237
  - **Transactional integration** — outbox + dedupe-on-receive inbox; exactly-once semantics across Postgres / SQLite (`b.outbox`, `b.inbox`); Debezium-shape change-event envelope on the outbox (`b.outbox.create({ envelope: "debezium" })`)
236
238
  - **Backup + restore** — end-to-end-encrypted bundles with pre-flush fail-closed mode + ML-DSA-87 signed manifests + scheduled backup-restore drills (`b.backup`, `b.backup.scheduleTest`, `b.backupBundle.verifyManifestSignature`); restore with pulled-bundle footprint preflight (`b.restore`); disaster-recovery runbook generator (HIPAA / PCI-DSS / GDPR / SOC 2 / DORA postures) (`b.drRunbook`)