@blamejs/blamejs-shop 0.1.35 → 0.1.36

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.36 (2026-05-26) — **Multi-currency display — shoppers browse prices in their own currency while you still charge in your base currency.** Shoppers can now pick a display currency from a switcher in the footer; every price on the storefront — product pages, the grid, search, and the cart — re-renders in that currency using the foreign-exchange rates you set, formatted with the right symbol, grouping, and decimal places for the locale. Conversion is display-only: the cart line and the eventual charge stay in your base currency, and a "Prices shown in EUR; you'll be charged in USD" disclosure makes that explicit whenever a conversion is in effect. The choice rides in a sealed first-party cookie and survives navigation; a per-currency rounding rule can snap converted prices to a tidy increment (for example Swiss 0.05 Rappenrundung). Every failure mode falls back to the base currency rather than showing a broken price: a currency with no rate on file, a rate that has expired, a currency you've removed from the allow-list, or a tampered cookie all render in your base currency. The switcher and all conversions render identically from the edge and the container, and the whole flow works with JavaScript off. **Added:** *Currency switcher + on-page price conversion* — A currency switcher in the footer lists the currencies you've enabled; choosing one `POST`s to `/currency`, stores the choice in a sealed cookie, and redirects back to the page the shopper was on. From then on product pages, the product grid, search results, and the cart display prices converted into that currency, each formatted for its locale (symbol, thousands separator, decimal places). When a conversion is active a "Prices shown in <display>; you'll be charged in <base>" note appears so the shopper always knows the charge currency. The switcher submits without JavaScript — the small enhancement script only auto-submits on change. · *Operator-set FX rates, display rounding, and a base-currency guarantee* — Set your base currency and the enabled list with `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or the `shop.base_currency` / `shop.currencies` config keys), and supply foreign-exchange rates yourself from whatever source you trust — the framework never reaches out to a rate feed. Rates are stored as integer basis points and conversion uses banker's rounding; an optional per-currency rounding rule snaps the result to a display increment (e.g. nearest 0.05). Conversion is strictly for display: the cart line's currency and amount, and therefore the amount charged at checkout, always stay in the base currency. Any currency without a usable rate — missing, expired, de-listed, or a tampered cookie — falls back to base-currency display instead of a broken or `NaN` price, so a stale or absent rate feed never breaks a priced page.
12
+
11
13
  - 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
14
 
13
15
  - 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.
package/README.md CHANGED
@@ -81,6 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
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
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. |
84
+ | **`lib/currency-display.js`** | Multi-currency display. Shoppers pick a display currency from the footer switcher (`POST /currency`, sealed cookie, 303 back); product pages, the grid, search, and the cart then show prices converted into it via operator-set FX rates (stored as integer basis points, banker's rounding), formatted per the currency's locale. Conversion is display-only — the cart line and the charged amount stay in the base currency, and a "Prices shown in <display>; you'll be charged in <base>" disclosure appears whenever converting. An optional per-currency rounding rule (composed with `lib/currency-rounding.js`) snaps converted prices to a display increment (e.g. CHF 0.05). Every failure mode — no rate on file, an expired rate, a de-listed currency, a tampered cookie — falls back to base-currency display, never a broken / `NaN` price. Base + enabled list come from `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or `shop.base_currency` / `shop.currencies` config). Switcher + conversions are server-rendered (work with JS off) and identical at the edge and container. |
84
85
  | **`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). |
85
86
  | **`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). |
86
87
  | **`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,13 @@
1
1
  {
2
- "version": "0.1.35",
2
+ "version": "0.1.36",
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-eNBBXiZ+B0S2drwiWoHzw7FzZRNGRO79o9VIwsaujSHTxQLz2zJabp0x74kFxAxq",
10
- "fingerprinted": "css/main.0ef22690caec76ea.css"
9
+ "integrity": "sha384-oCNOJjwj7/EHXCrMnkhOTDY/5iFDeIvpWFmMyeX1g73CE4usg4i4q/dGYx4Ko258",
10
+ "fingerprinted": "css/main.0cbb632d5418155e.css"
11
11
  },
12
12
  "js/consent.js": {
13
13
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
@@ -53,6 +53,7 @@
53
53
 
54
54
  var b = require("./vendor/blamejs");
55
55
  var C = b.constants;
56
+ var currencyRounding = require("./currency-rounding");
56
57
 
57
58
  var CURRENCY_RE = /^[A-Z]{3}$/;
58
59
  var DEFAULT_TTL_MS = C.TIME.days(1);
@@ -409,10 +410,38 @@ function create(opts) {
409
410
  return { written: written, fetched_at: fetchedAt };
410
411
  }
411
412
 
413
+ // Display-side read: the rate to use when rendering `base` prices in
414
+ // `quote`, or null when there's nothing usable. Returns null (rather
415
+ // than the stale-flagged row `getRate` returns) for a missing row, an
416
+ // expired row, OR a quote outside the operator's allow-list — the three
417
+ // cases the storefront treats identically: fall back to the base
418
+ // currency. Missing-table-resilient: a query that throws (the
419
+ // `fx_rates` table not migrated on this deploy) reads as "no rate", so
420
+ // a storefront without the table degrades to base display rather than
421
+ // 500-ing every priced page. The `at` epoch-ms parameter overrides
422
+ // "now" for the freshness check (tests, deterministic replay).
423
+ async function getRateForDisplay(base, quote, at) {
424
+ _assertCurrency(base, "base");
425
+ _assertCurrency(quote, "quote");
426
+ if (base === quote) return { rate_bps: BPS_SCALE, stale: false };
427
+ if (supported.indexOf(quote) === -1) return null;
428
+ var now = at == null ? _now() : at;
429
+ var row;
430
+ try {
431
+ row = await getRate(base, quote);
432
+ } catch (_e) {
433
+ // fx_rates table absent / query failure — degrade to base display.
434
+ return null;
435
+ }
436
+ if (!row || row.expires_at < now) return null;
437
+ return { rate_bps: row.rate_bps, stale: false, source: row.source, fetched_at: row.fetched_at };
438
+ }
439
+
412
440
  return {
413
441
  supportedCurrencies: supported.slice(),
414
442
  setRate: setRate,
415
443
  getRate: getRate,
444
+ getRateForDisplay: getRateForDisplay,
416
445
  convert: convert,
417
446
  format: format,
418
447
  convertAndFormat: convertAndFormat,
@@ -421,9 +450,177 @@ function create(opts) {
421
450
  };
422
451
  }
423
452
 
453
+ // ---- display presenter (request-scoped) ---------------------------------
454
+ //
455
+ // The storefront renders a price string at dozens of call sites (PDP,
456
+ // catalog grids, search cards, cart lines + totals). Those renderers are
457
+ // synchronous; the FX-rate + rounding-rule lookups are async D1 reads.
458
+ // The presenter resolves the rate + rule ONCE per request (`load`), then
459
+ // hands the renderers a synchronous `format(amount_minor, currency)` that
460
+ // converts the catalog's base-currency price into the visitor's chosen
461
+ // display currency and formats it for that currency's locale.
462
+ //
463
+ // This is DISPLAY-ONLY. The cart / order / payment currency is unchanged
464
+ // — `pricing.format` + the stored order totals remain authoritative for
465
+ // what the customer is charged. The presenter only transforms the string
466
+ // the customer reads. When the display currency is the base currency (or
467
+ // unset / unknown / has no usable FX rate), `format` is exactly
468
+ // `b.money.fromMinorUnits(...).format(locale)` over the base amount.
469
+
470
+ // Default BCP 47 locale per currency so a EUR price renders "27,59 €"
471
+ // and a JPY price renders "¥3,200" without the operator wiring a locale
472
+ // table. The visitor's Accept-Language can override via `opts.locale`.
473
+ var DEFAULT_LOCALE_BY_CURRENCY = {
474
+ USD: "en-US", CAD: "en-CA", AUD: "en-AU", NZD: "en-NZ", SGD: "en-SG",
475
+ HKD: "en-HK", INR: "en-IN", GBP: "en-GB", EUR: "de-DE", CHF: "de-CH",
476
+ SEK: "sv-SE", NOK: "nb-NO", DKK: "da-DK", JPY: "ja-JP", KRW: "ko-KR",
477
+ BRL: "pt-BR", MXN: "es-MX",
478
+ };
479
+
480
+ function _localeFor(currency, fallback) {
481
+ return DEFAULT_LOCALE_BY_CURRENCY[currency] || fallback || "en-US";
482
+ }
483
+
484
+ // Pure base→display minor-unit conversion. Composes b.money.convert (the
485
+ // framework's banker's-rounding FX step, ISO 4217 exponent-aware) for the
486
+ // currency change, then the pure currency-rounding step for the display
487
+ // increment rule (CHF 0.05, SEK 0.10, …). No I/O, no audit-log write —
488
+ // the audit log is for committed checkout roundings, not the read-side
489
+ // display render. `rateBps` is the integer basis-points rate
490
+ // (rate * 10000); `rule` is the shaped rounding rule (or null for none).
491
+ function _convertMinorForDisplay(baseMinor, baseCurrency, displayCurrency, rateBps, rule) {
492
+ var convertedMinor;
493
+ if (baseCurrency === displayCurrency || rateBps === BPS_SCALE) {
494
+ convertedMinor = baseMinor;
495
+ } else {
496
+ var money = b.money.fromMinorUnits(BigInt(baseMinor), baseCurrency);
497
+ var rateStr = _bpsToDecimal(rateBps);
498
+ var provider = { rate: function (_from, _to) { return rateStr; } };
499
+ convertedMinor = Number(b.money.convert(money, displayCurrency, provider).toMinorUnits());
500
+ }
501
+ // Display-increment rounding — only when an active rule covers the
502
+ // display-side render. The rule's `applies_to` is honoured: a rule
503
+ // scoped to `cart_total` only does not touch a PDP price.
504
+ if (rule && rule.increment_minor > 1 &&
505
+ (rule.applies_to === "all" || rule.applies_to === "display_only")) {
506
+ convertedMinor = currencyRounding.round(convertedMinor, rule.increment_minor, rule.mode);
507
+ }
508
+ return convertedMinor;
509
+ }
510
+
511
+ // Build a presenter for ONE request. `displayCurrency` is the visitor's
512
+ // chosen currency (from the sealed cookie); `baseCurrency` is the
513
+ // catalog's settlement currency (USD by default). Resolves the FX rate +
514
+ // rounding rule up front so the returned `format` is synchronous.
515
+ //
516
+ // opts.fx — a currencyDisplay.create() instance (FX reads)
517
+ // opts.rounding — a currencyRounding.create() instance (optional)
518
+ // opts.displayCurrency — the visitor's choice (may be null / unknown)
519
+ // opts.baseCurrency — catalog settlement currency (default "USD")
520
+ // opts.locale — override the per-currency default locale
521
+ // opts.at — epoch-ms freshness override (tests)
522
+ //
523
+ // When the display currency equals the base, is unset, is unsupported, or
524
+ // has no usable FX rate, the presenter resolves to an INACTIVE presenter
525
+ // that formats in the base currency — never a broken / NaN price.
526
+ async function loadPresenter(opts) {
527
+ opts = opts || {};
528
+ var baseCurrency = opts.baseCurrency == null ? "USD" : opts.baseCurrency;
529
+ _assertCurrency(baseCurrency, "baseCurrency");
530
+ var requested = opts.displayCurrency;
531
+
532
+ // No / garbage choice, or the base currency itself → base presenter.
533
+ if (typeof requested !== "string" || !CURRENCY_RE.test(requested) || requested === baseCurrency) {
534
+ return _basePresenter(baseCurrency, opts.locale);
535
+ }
536
+ if (!opts.fx || typeof opts.fx.getRateForDisplay !== "function") {
537
+ return _basePresenter(baseCurrency, opts.locale);
538
+ }
539
+ // Outside the operator's allow-list → base presenter (and the switcher
540
+ // never lists it, so this is the tampered-cookie path).
541
+ if (opts.fx.supportedCurrencies && opts.fx.supportedCurrencies.indexOf(requested) === -1) {
542
+ return _basePresenter(baseCurrency, opts.locale);
543
+ }
544
+
545
+ var rate = await opts.fx.getRateForDisplay(baseCurrency, requested, opts.at);
546
+ if (!rate) {
547
+ // No usable FX rate (missing / stale / table absent) → base display.
548
+ return _basePresenter(baseCurrency, opts.locale);
549
+ }
550
+
551
+ var rule = null;
552
+ if (opts.rounding && typeof opts.rounding.getRule === "function") {
553
+ try {
554
+ rule = await opts.rounding.getRule(requested);
555
+ if (rule && !rule.active) rule = null;
556
+ } catch (_e) {
557
+ // currency_rounding_rules table absent / read failure — convert
558
+ // without the display-increment step. Banker's rounding from the
559
+ // FX step still applies, so the price is never broken.
560
+ rule = null;
561
+ }
562
+ }
563
+
564
+ var locale = opts.locale || _localeFor(requested);
565
+ var rateBps = rate.rate_bps;
566
+
567
+ return {
568
+ active: true,
569
+ baseCurrency: baseCurrency,
570
+ displayCurrency: requested,
571
+ locale: locale,
572
+ note: _disclosure(requested, baseCurrency),
573
+ // Synchronous price renderer. `currency` is the price's stored
574
+ // currency; when it matches the base we convert to the display
575
+ // currency, otherwise we format it as-is (a price already quoted in
576
+ // a non-base currency isn't re-converted — we have no base→that rate
577
+ // resolved this request, and re-quoting it would be wrong).
578
+ format: function (amountMinor, currency) {
579
+ var cur = currency == null ? baseCurrency : currency;
580
+ if (cur !== baseCurrency) {
581
+ return b.money.fromMinorUnits(BigInt(amountMinor), cur).format(_localeFor(cur, "en-US"));
582
+ }
583
+ var displayMinor = _convertMinorForDisplay(amountMinor, baseCurrency, requested, rateBps, rule);
584
+ return b.money.fromMinorUnits(BigInt(displayMinor), requested).format(locale);
585
+ },
586
+ };
587
+ }
588
+
589
+ // The pass-through presenter — `format` is byte-for-byte `pricing.format`
590
+ // (b.money over the stored amount + currency). Used whenever no display
591
+ // conversion applies, so every call site can use `presenter.format`
592
+ // unconditionally.
593
+ function _basePresenter(baseCurrency, localeOverride) {
594
+ return {
595
+ active: false,
596
+ baseCurrency: baseCurrency,
597
+ displayCurrency: baseCurrency,
598
+ locale: localeOverride || _localeFor(baseCurrency),
599
+ note: null,
600
+ format: function (amountMinor, currency) {
601
+ var cur = currency == null ? baseCurrency : currency;
602
+ var loc = localeOverride || _localeFor(cur, "en-US");
603
+ return b.money.fromMinorUnits(BigInt(amountMinor), cur).format(loc);
604
+ },
605
+ };
606
+ }
607
+
608
+ // The customer-facing disclosure shown near converted prices: the charge
609
+ // stays in the settlement currency even though the display is converted.
610
+ function _disclosure(displayCurrency, baseCurrency) {
611
+ return "Prices shown in " + displayCurrency + "; you'll be charged in " + baseCurrency + ".";
612
+ }
613
+
424
614
  module.exports = {
425
- create: create,
426
- DEFAULT_TTL_MS: DEFAULT_TTL_MS,
427
- DEFAULT_SUPPORTED: DEFAULT_SUPPORTED,
428
- BPS_SCALE: BPS_SCALE,
615
+ create: create,
616
+ loadPresenter: loadPresenter,
617
+ DEFAULT_TTL_MS: DEFAULT_TTL_MS,
618
+ DEFAULT_SUPPORTED: DEFAULT_SUPPORTED,
619
+ BPS_SCALE: BPS_SCALE,
620
+ DEFAULT_LOCALE_BY_CURRENCY: DEFAULT_LOCALE_BY_CURRENCY,
621
+ // Pure conversion exposed so the edge worker computes byte-identical
622
+ // display prices from the same rate + rule inputs.
623
+ convertMinorForDisplay: _convertMinorForDisplay,
624
+ localeFor: _localeFor,
625
+ disclosure: _disclosure,
429
626
  };
package/lib/storefront.js CHANGED
@@ -27,6 +27,7 @@
27
27
 
28
28
  var emailModule = require("./email");
29
29
  var pricing = require("./pricing");
30
+ var currencyDisplayModule = require("./currency-display");
30
31
 
31
32
  var b = require("./vendor/blamejs");
32
33
 
@@ -234,6 +235,7 @@ var LAYOUT =
234
235
  " </ul>\n" +
235
236
  " </div>\n" +
236
237
  " </div>\n" +
238
+ " RAW_CURRENCY_SWITCHER\n" +
237
239
  " <div class=\"site-footer__copy\">\n" +
238
240
  " <p>&copy; {{year}} {{shop_name}} — built on blamejs · Apache 2.0 licensed.</p>\n" +
239
241
  " <ul>\n" +
@@ -315,6 +317,63 @@ function _islandScript(name, opts) {
315
317
  (sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
316
318
  }
317
319
 
320
+ // Multi-currency display switcher — a GET form in the footer listing the
321
+ // operator's display currencies. Selecting one POSTs to /currency, which
322
+ // sets the sealed `shop_ccy` cookie and redirects back. The currently
323
+ // selected currency is pre-checked. Absent a presenter (feature not
324
+ // wired) the whole block is empty so older deploys render unchanged.
325
+ // `currencies` is the operator's allow-list; `selected` is the active
326
+ // display currency; `note` is the "charged in <base>" disclosure (present
327
+ // only when a non-base currency is active).
328
+ function _buildCurrencySwitcher(opts) {
329
+ if (!opts || !Array.isArray(opts.currencies) || opts.currencies.length < 2) return "";
330
+ var esc = function (s) { return b.template.escapeHtml(String(s)); };
331
+ var selected = opts.selected || opts.currencies[0];
332
+ var options = opts.currencies.map(function (c) {
333
+ var sel = c === selected ? " selected" : "";
334
+ return "<option value=\"" + esc(c) + "\"" + sel + ">" + esc(c) + "</option>";
335
+ }).join("");
336
+ // GET-action would expose the choice in the URL + cache key; a POST
337
+ // keeps it in the sealed cookie and bypasses the edge cache via the
338
+ // 303 redirect. `redirect_to` carries the current path so the visitor
339
+ // lands back where they were. The form auto-submits via the island
340
+ // script when present; without JS the explicit "Set" button submits.
341
+ var noteHtml = opts.note
342
+ ? "<p class=\"currency-switcher__note\">" + esc(opts.note) + "</p>"
343
+ : "";
344
+ return "<div class=\"currency-switcher\">\n" +
345
+ " <form class=\"currency-switcher__form\" method=\"post\" action=\"/currency\">\n" +
346
+ " <input type=\"hidden\" name=\"redirect_to\" value=\"" + esc(opts.redirect_to || "/") + "\">\n" +
347
+ " <label class=\"currency-switcher__label\" for=\"currency-select\">Display currency</label>\n" +
348
+ " <select id=\"currency-select\" name=\"currency\" class=\"currency-switcher__select\" data-currency-switcher>" + options + "</select>\n" +
349
+ " <button type=\"submit\" class=\"currency-switcher__btn\">Set</button>\n" +
350
+ " </form>\n" +
351
+ " " + noteHtml + "\n" +
352
+ " </div>";
353
+ }
354
+
355
+ // The per-request price formatter a renderer uses for every displayed
356
+ // price. When the route handler resolved a display-currency presenter and
357
+ // threaded it in as `opts.format_price`, that's used (it converts base →
358
+ // display currency + applies the display rounding rule). Otherwise the
359
+ // renderer falls back to `pricing.format` — identical to pre-feature
360
+ // behaviour, so an un-wired store or any renderer the route handler
361
+ // didn't thread keeps formatting in the catalog currency.
362
+ function _priceFormatter(opts) {
363
+ return (opts && typeof opts.format_price === "function") ? opts.format_price : pricing.format;
364
+ }
365
+
366
+ // Lift the currency-switcher fields off a renderer's opts so they reach
367
+ // `_wrap` unchanged. Keeps the per-renderer `_wrap` call sites short.
368
+ function _currencyWrapOpts(opts) {
369
+ return {
370
+ currency_options: opts.currency_options,
371
+ currency_selected: opts.currency_selected,
372
+ currency_note: opts.currency_note,
373
+ currency_redirect_to: opts.currency_redirect_to,
374
+ };
375
+ }
376
+
318
377
  function _wrap(opts) {
319
378
  var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
320
379
  ? opts.theme_css
@@ -333,6 +392,15 @@ function _wrap(opts) {
333
392
  var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
334
393
  var ogImage = opts.og_image || "/assets/brand/logo.png";
335
394
  var ogUrl = opts.og_url || "";
395
+ // Multi-currency display switcher — populated only when the operator
396
+ // configured >1 display currency (opts.currency_options). The block is
397
+ // empty otherwise, so a single-currency store renders unchanged.
398
+ var switcherHtml = _buildCurrencySwitcher({
399
+ currencies: opts.currency_options,
400
+ selected: opts.currency_selected,
401
+ note: opts.currency_note,
402
+ redirect_to: opts.currency_redirect_to,
403
+ });
336
404
  return _render(LAYOUT, {
337
405
  title: opts.title,
338
406
  shop_name: shopName,
@@ -348,6 +416,7 @@ function _wrap(opts) {
348
416
  body: "RAW_BODY_PLACEHOLDER",
349
417
  }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
350
418
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
419
+ .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
351
420
  .replace("RAW_BODY_PLACEHOLDER", opts.body);
352
421
  // The body is RAW HTML (already rendered + escaped at the
353
422
  // per-fragment level). The placeholder swap is post-render so the
@@ -607,9 +676,10 @@ function renderHome(opts) {
607
676
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
608
677
  var title = opts.title || "Shop";
609
678
  var assetPrefix = opts.asset_prefix || "/assets/";
679
+ var fmt = _priceFormatter(opts);
610
680
  var products = opts.products.map(function (p) {
611
681
  var priceStr = p.starting_price_minor != null
612
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
682
+ ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
613
683
  : "—";
614
684
  // Hero image — first media row attached to the product (the
615
685
  // route handler bundles it in via `p.hero_media`). Image-less
@@ -684,13 +754,13 @@ function renderHome(opts) {
684
754
  // designed surface even when no products are loaded yet —
685
755
  // visitors land on the storefront shell, not a tech demo.
686
756
  var body = hero + catalog;
687
- return _wrap({
757
+ return _wrap(Object.assign({
688
758
  title: title,
689
759
  shop_name: shopName,
690
760
  cart_count: cartCount,
691
761
  theme_css: opts.theme_css,
692
762
  body: body,
693
- });
763
+ }, _currencyWrapOpts(opts)));
694
764
  }
695
765
 
696
766
  // ---- search results -----------------------------------------------------
@@ -914,9 +984,10 @@ function renderSearch(opts) {
914
984
  .replace("RAW_CLEAR", clearLink);
915
985
  } else {
916
986
  var assetPrefix = opts.asset_prefix || "/assets/";
987
+ var fmt = _priceFormatter(opts);
917
988
  var cards = products.map(function (p) {
918
989
  var priceStr = p.starting_price_minor != null
919
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
990
+ ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
920
991
  : "—";
921
992
  var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
922
993
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
@@ -932,14 +1003,14 @@ function renderSearch(opts) {
932
1003
  } else {
933
1004
  body = header + correctionHtml + chipsHtml + resultsInner;
934
1005
  }
935
- return _wrap({
1006
+ return _wrap(Object.assign({
936
1007
  title: "Search",
937
1008
  shop_name: opts.shop_name || "blamejs.shop",
938
1009
  cart_count: opts.cart_count,
939
1010
  search_q: opts.q,
940
1011
  theme_css: opts.theme_css,
941
1012
  body: body,
942
- });
1013
+ }, _currencyWrapOpts(opts)));
943
1014
  }
944
1015
 
945
1016
  // ---- product detail -----------------------------------------------------
@@ -2654,9 +2725,10 @@ function renderProduct(opts) {
2654
2725
  var shopName = opts.shop_name || "blamejs.shop";
2655
2726
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
2656
2727
  var description = opts.product.description || "";
2728
+ var fmt = _priceFormatter(opts);
2657
2729
  var rendered = variants.map(function (v) {
2658
2730
  var price = prices[v.id];
2659
- var priceStr = price ? pricing.format(price.amount_minor, price.currency) : "—";
2731
+ var priceStr = price ? fmt(price.amount_minor, price.currency) : "—";
2660
2732
  var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
2661
2733
  return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
2662
2734
  });
@@ -2762,7 +2834,7 @@ function renderProduct(opts) {
2762
2834
  });
2763
2835
  jsonLd = (jsonLd || "") + breadcrumbJsonLd;
2764
2836
 
2765
- return _wrap({
2837
+ return _wrap(Object.assign({
2766
2838
  title: opts.product.title,
2767
2839
  shop_name: shopName,
2768
2840
  cart_count: cartCount,
@@ -2772,7 +2844,7 @@ function renderProduct(opts) {
2772
2844
  og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
2773
2845
  og_image: ogImage,
2774
2846
  body: body + jsonLd,
2775
- });
2847
+ }, _currencyWrapOpts(opts)));
2776
2848
  }
2777
2849
 
2778
2850
  // ---- cart --------------------------------------------------------------
@@ -3299,6 +3371,7 @@ function renderCart(opts) {
3299
3371
  // route handler bundles it in. Lines without an entry render with
3300
3372
  // a dashed-placeholder tile + the SKU as the fallback title.
3301
3373
  var lookup = opts.product_lookup || {};
3374
+ var fmt = _priceFormatter(opts);
3302
3375
  var rendered = lines.map(function (l) {
3303
3376
  var match = lookup[l.variant_id] || null;
3304
3377
  var prod = match && match.product;
@@ -3309,16 +3382,16 @@ function renderCart(opts) {
3309
3382
  id: l.id,
3310
3383
  sku: l.sku,
3311
3384
  qty: String(l.qty),
3312
- unit: pricing.format(l.unit_amount_minor, l.unit_currency),
3313
- total: pricing.format(l.qty * l.unit_amount_minor, l.unit_currency),
3385
+ unit: fmt(l.unit_amount_minor, l.unit_currency),
3386
+ total: fmt(l.qty * l.unit_amount_minor, l.unit_currency),
3314
3387
  product_title: (prod && prod.title) || l.sku,
3315
3388
  product_url: prod ? ("/products/" + prod.slug) : "#",
3316
3389
  image_url: imageUrl,
3317
3390
  image_alt: imageAlt,
3318
3391
  };
3319
3392
  });
3320
- var subtotal = pricing.format(totals.subtotal_minor, totals.currency);
3321
- var total = pricing.format(totals.grand_total_minor, totals.currency);
3393
+ var subtotal = fmt(totals.subtotal_minor, totals.currency);
3394
+ var total = fmt(totals.grand_total_minor, totals.currency);
3322
3395
  if (opts.theme) {
3323
3396
  return opts.theme.render("cart", {
3324
3397
  title: "Cart",
@@ -3364,13 +3437,13 @@ function renderCart(opts) {
3364
3437
  total: total,
3365
3438
  }).replace("RAW_LINES", rows);
3366
3439
  }
3367
- return _wrap({
3440
+ return _wrap(Object.assign({
3368
3441
  title: "Cart",
3369
3442
  shop_name: shopName,
3370
3443
  cart_count: lines.length,
3371
3444
  theme_css: opts.theme_css,
3372
3445
  body: body,
3373
- });
3446
+ }, _currencyWrapOpts(opts)));
3374
3447
  }
3375
3448
 
3376
3449
  // ---- admin landing (HTML — the rest of /admin/* is JSON API) ----------
@@ -3637,6 +3710,14 @@ var OAUTH_COOKIE_NAME = "shop_oauth";
3637
3710
  // Sealed so it can't be forged to mis-attribute a signup.
3638
3711
  var REFERRAL_COOKIE_NAME = "shop_ref";
3639
3712
 
3713
+ // Sealed cookie holding the visitor's chosen DISPLAY currency (ISO 4217).
3714
+ // Display-only: the cart / order / payment currency is unchanged — this
3715
+ // only selects which currency the price strings are rendered in. Sealed
3716
+ // so a tampered value can't smuggle a non-allow-listed code past the
3717
+ // reader; a garbage value reads as "unset" and the storefront renders in
3718
+ // the base currency. Path "/" so the choice persists across the catalog.
3719
+ var CURRENCY_COOKIE_NAME = "shop_ccy";
3720
+
3640
3721
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
3641
3722
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
3642
3723
 
@@ -3864,6 +3945,24 @@ function _uaClass(req) {
3864
3945
  return "unknown";
3865
3946
  }
3866
3947
 
3948
+ var CCY_SHAPE_RE = /^[A-Z]{3}$/;
3949
+
3950
+ // The visitor's chosen display currency, or null. Sealed so the value
3951
+ // can't be forged; a missing / malformed / mis-shaped value reads as null
3952
+ // (→ base-currency display), never throws.
3953
+ function _readCurrencyCookie(req) {
3954
+ var raw = _cookieJar().readSealed(req, CURRENCY_COOKIE_NAME);
3955
+ if (raw === null || !CCY_SHAPE_RE.test(raw)) return null;
3956
+ return raw;
3957
+ }
3958
+ function _setCurrencyCookie(res, code) {
3959
+ var T = b.constants.TIME;
3960
+ _cookieJar().writeSealed(res, CURRENCY_COOKIE_NAME, code, { expires: new Date(Date.now() + T.days(180)) });
3961
+ }
3962
+ function _clearCurrencyCookie(res) {
3963
+ _cookieJar().clear(res, CURRENCY_COOKIE_NAME);
3964
+ }
3965
+
3867
3966
  // ---- account-page renderers --------------------------------------------
3868
3967
 
3869
3968
  var ACCOUNT_LOGIN_PAGE =
@@ -4131,6 +4230,76 @@ function mount(router, deps) {
4131
4230
  return lines.length;
4132
4231
  }
4133
4232
 
4233
+ // ---- multi-currency display -------------------------------------------
4234
+ //
4235
+ // Opt-in: wired only when the operator supplies `deps.currencyDisplay`
4236
+ // (an FX-rate cache instance) AND `deps.currency_display_options` (the
4237
+ // allow-list of display currencies, base first). Absent either, every
4238
+ // render call gets an empty currency bundle and prices stay in the base
4239
+ // currency exactly as before. `deps.currencyRounding` is optional —
4240
+ // present, it applies the per-currency display-increment rule (CHF 0.05,
4241
+ // SEK 0.10); absent, conversion uses banker's rounding only.
4242
+ var _ccyBase = (deps.currency_base || "USD").toUpperCase();
4243
+ var _ccyOptions = Array.isArray(deps.currency_display_options)
4244
+ ? deps.currency_display_options.map(function (c) { return String(c).toUpperCase(); })
4245
+ : null;
4246
+ // The feature mounts (the /currency route + the per-request bundle)
4247
+ // whenever an FX instance is wired AND the boot-time allow-list names
4248
+ // >1 currency. The per-request `currency_config` resolver can narrow /
4249
+ // widen the live allow-list, but mounting is decided once at boot from
4250
+ // the seed so a single-currency store never registers the route.
4251
+ var _ccyEnabled = !!(deps.currencyDisplay && _ccyOptions && _ccyOptions.length > 1);
4252
+ var _ccyConfig = typeof deps.currency_config === "function" ? deps.currency_config : null;
4253
+
4254
+ // Per-request currency bundle merged into every render call's opts:
4255
+ // format_price — sync base→display formatter (or pricing.format)
4256
+ // currency_options — the switcher's option list
4257
+ // currency_selected — the visitor's active display currency
4258
+ // currency_note — "charged in <base>" disclosure (when converting)
4259
+ // currency_redirect_to — the path the switcher returns the visitor to
4260
+ // A read / conversion failure degrades to the base bundle (prices in the
4261
+ // base currency) — a broken FX backend never breaks a priced page.
4262
+ async function _currencyForReq(req) {
4263
+ if (!_ccyEnabled) return {};
4264
+ var path = String((req && req.url) || "/").split("?")[0] || "/";
4265
+ // Resolve the live base + allow-list (config override, else the boot
4266
+ // seed). A resolver failure falls back to the seed.
4267
+ var base = _ccyBase;
4268
+ var options = _ccyOptions;
4269
+ if (_ccyConfig) {
4270
+ try {
4271
+ var cfg = await _ccyConfig();
4272
+ if (cfg && cfg.base) base = cfg.base;
4273
+ if (cfg && Array.isArray(cfg.options) && cfg.options.length) options = cfg.options;
4274
+ } catch (_e) { /* keep the boot seed */ }
4275
+ }
4276
+ var bundle = {
4277
+ currency_options: options,
4278
+ currency_selected: base,
4279
+ currency_redirect_to: path,
4280
+ };
4281
+ var chosen = _readCurrencyCookie(req);
4282
+ try {
4283
+ var presenter = await currencyDisplayModule.loadPresenter({
4284
+ fx: deps.currencyDisplay,
4285
+ rounding: deps.currencyRounding || null,
4286
+ baseCurrency: base,
4287
+ displayCurrency: chosen,
4288
+ });
4289
+ // The presenter only activates when the chosen currency is in the
4290
+ // live allow-list AND has a usable rate. A cookie naming a currency
4291
+ // the operator since removed from the list resolves to base.
4292
+ if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
4293
+ bundle.format_price = presenter.format;
4294
+ bundle.currency_selected = presenter.displayCurrency;
4295
+ if (presenter.note) bundle.currency_note = presenter.note;
4296
+ }
4297
+ } catch (_e) {
4298
+ // FX / rounding backend unavailable — fall back to base display.
4299
+ }
4300
+ return bundle;
4301
+ }
4302
+
4134
4303
  // Absolute shareable referral link for a code. Prefers the operator's
4135
4304
  // configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
4136
4305
  // stable across the edge/container split; falls back to the request's
@@ -4254,8 +4423,9 @@ function mount(router, deps) {
4254
4423
  // reachable via multiple variant SKUs. Drop-silent on a primitive
4255
4424
  // error so a bundles read failure can't 500 the PDP — the rail just
4256
4425
  // doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
4257
- async function _resolveBundleOffers(variantSkus, currency) {
4426
+ async function _resolveBundleOffers(variantSkus, currency, fmtPrice) {
4258
4427
  if (!deps.bundles) return [];
4428
+ var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
4259
4429
  var seen = Object.create(null);
4260
4430
  var offers = [];
4261
4431
  try {
@@ -4300,9 +4470,9 @@ function mount(router, deps) {
4300
4470
  bundle_sku: bundle.bundle_sku,
4301
4471
  title: bundle.title,
4302
4472
  components: componentsOut,
4303
- list_total_str: pricing.format(listMinor, cur),
4304
- amount_str: pricing.format(amountMinor, cur),
4305
- discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
4473
+ list_total_str: fmt(listMinor, cur),
4474
+ amount_str: fmt(amountMinor, cur),
4475
+ discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
4306
4476
  available: available,
4307
4477
  unavailable_reason: available
4308
4478
  ? null
@@ -4326,8 +4496,9 @@ function mount(router, deps) {
4326
4496
  // that quantity. Rows read as ascending ranges ("1–4", "5–9",
4327
4497
  // "10+"). Returns [] when no active sku-scoped tier set exists.
4328
4498
  // Drop-silent on a read failure so the PDP still renders.
4329
- async function _resolveQtyBreaks(sku, unitMinor, currency) {
4499
+ async function _resolveQtyBreaks(sku, unitMinor, currency, fmtPrice) {
4330
4500
  if (!deps.quantityDiscounts || unitMinor == null) return [];
4501
+ var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
4331
4502
  var breakdown;
4332
4503
  try {
4333
4504
  breakdown = await deps.quantityDiscounts.tierBreakdown({
@@ -4350,7 +4521,7 @@ function mount(router, deps) {
4350
4521
  if (sorted[0].min_quantity > 1) {
4351
4522
  out.push({
4352
4523
  label: "1–" + (sorted[0].min_quantity - 1),
4353
- unit_str: pricing.format(unitMinor, currency),
4524
+ unit_str: fmt(unitMinor, currency),
4354
4525
  });
4355
4526
  }
4356
4527
  for (var i = 0; i < sorted.length; i += 1) {
@@ -4358,7 +4529,7 @@ function mount(router, deps) {
4358
4529
  var next = sorted[i + 1];
4359
4530
  var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
4360
4531
  var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
4361
- out.push({ label: label, unit_str: pricing.format(unit, currency) });
4532
+ out.push({ label: label, unit_str: fmt(unit, currency) });
4362
4533
  }
4363
4534
  return out;
4364
4535
  }
@@ -4398,7 +4569,35 @@ function mount(router, deps) {
4398
4569
  return out;
4399
4570
  }
4400
4571
 
4401
- router.get("/", async function (_req, res) {
4572
+ // POST /currency set (or clear) the visitor's display-currency choice.
4573
+ // Registered only when multi-currency display is wired. Display-only:
4574
+ // this NEVER touches the cart / order / payment currency — it sets the
4575
+ // sealed `shop_ccy` cookie that selects which currency price strings are
4576
+ // rendered in, then 303-redirects back so the new choice takes effect on
4577
+ // the next render. A choice outside the operator's allow-list clears the
4578
+ // cookie (→ base-currency display) rather than persisting a code the
4579
+ // switcher would never offer.
4580
+ if (_ccyEnabled) {
4581
+ router.post("/currency", function (req, res) {
4582
+ var body = req.body || {};
4583
+ var chosen = typeof body.currency === "string" ? body.currency.toUpperCase() : "";
4584
+ // `redirect_to` is constrained to a same-origin path (leading single
4585
+ // slash, no scheme / host / protocol-relative `//`) so the switcher
4586
+ // can't be turned into an open redirect.
4587
+ var rawTo = typeof body.redirect_to === "string" ? body.redirect_to : "/";
4588
+ var to = (/^\/(?!\/)/.test(rawTo)) ? rawTo : "/";
4589
+ if (chosen === _ccyBase || _ccyOptions.indexOf(chosen) === -1) {
4590
+ _clearCurrencyCookie(res);
4591
+ } else {
4592
+ _setCurrencyCookie(res, chosen);
4593
+ }
4594
+ res.status(303);
4595
+ res.setHeader && res.setHeader("location", to);
4596
+ return res.end ? res.end() : res.send("");
4597
+ });
4598
+ }
4599
+
4600
+ router.get("/", async function (req, res) {
4402
4601
  var page = await deps.catalog.products.list({ status: "active", limit: 24 });
4403
4602
  // Best-effort "starting price" + "hero media" lookup. Each
4404
4603
  // product on the home grid carries its first variant's USD
@@ -4423,7 +4622,8 @@ function mount(router, deps) {
4423
4622
  hero_media: heroMedia,
4424
4623
  }));
4425
4624
  }
4426
- var html = renderHome({ products: products, shop_name: shopName, theme: theme });
4625
+ var ccy = await _currencyForReq(req);
4626
+ var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, ccy));
4427
4627
  _send(res, 200, html);
4428
4628
  });
4429
4629
 
@@ -4553,7 +4753,8 @@ function mount(router, deps) {
4553
4753
  cartCount = lines.length;
4554
4754
  }
4555
4755
  }
4556
- _send(res, 200, renderSearch({
4756
+ var ccy = await _currencyForReq(req);
4757
+ _send(res, 200, renderSearch(Object.assign({
4557
4758
  q: q,
4558
4759
  products: products,
4559
4760
  facets: facetGroups,
@@ -4561,7 +4762,7 @@ function mount(router, deps) {
4561
4762
  corrected_query: correctedQ,
4562
4763
  shop_name: shopName,
4563
4764
  cart_count: cartCount,
4564
- }));
4765
+ }, ccy)));
4565
4766
  });
4566
4767
 
4567
4768
  router.get("/products/:slug", async function (req, res) {
@@ -4654,14 +4855,16 @@ function mount(router, deps) {
4654
4855
  // so the buy path renders regardless. The quantity-break table is
4655
4856
  // built against the first variant's list price — the band a shopper
4656
4857
  // sees on the PDP matches what the cart charges at that quantity.
4858
+ var ccy = await _currencyForReq(req);
4859
+ var ccyFmt = ccy.format_price || null;
4657
4860
  var variantSkus = variants.map(function (v) { return v.sku; });
4658
- var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
4861
+ var bundleOffers = await _resolveBundleOffers(variantSkus, "USD", ccyFmt);
4659
4862
  var firstVariant = variants[0] || null;
4660
4863
  var firstPrice = firstVariant ? prices[firstVariant.id] : null;
4661
4864
  var qtyBreaks = firstVariant && firstPrice
4662
- ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
4865
+ ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
4663
4866
  : [];
4664
- var html = renderProduct({
4867
+ var html = renderProduct(Object.assign({
4665
4868
  product: product,
4666
4869
  variants: variants,
4667
4870
  prices: prices,
@@ -4677,7 +4880,7 @@ function mount(router, deps) {
4677
4880
  shop_name: shopName,
4678
4881
  cart_count: cartCount,
4679
4882
  theme: theme,
4680
- });
4883
+ }, ccy));
4681
4884
  _send(res, 200, html);
4682
4885
  });
4683
4886
 
@@ -4992,19 +5195,20 @@ function mount(router, deps) {
4992
5195
  }
4993
5196
 
4994
5197
  router.get("/cart", async function (req, res) {
5198
+ var ccy = await _currencyForReq(req);
4995
5199
  var sid = _readSidCookie(req);
4996
5200
  if (!sid) {
4997
- return _send(res, 200, renderCart({
5201
+ return _send(res, 200, renderCart(Object.assign({
4998
5202
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
4999
5203
  shop_name: shopName, theme: theme,
5000
- }));
5204
+ }, ccy)));
5001
5205
  }
5002
5206
  var c = await deps.cart.bySession(sid);
5003
5207
  if (!c) {
5004
- return _send(res, 200, renderCart({
5208
+ return _send(res, 200, renderCart(Object.assign({
5005
5209
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
5006
5210
  shop_name: shopName, theme: theme,
5007
- }));
5211
+ }, ccy)));
5008
5212
  }
5009
5213
  var rawLines = await deps.cart.listLines(c.id);
5010
5214
  // Reapply the active quantity-break for each line at its current
@@ -5031,14 +5235,14 @@ function mount(router, deps) {
5031
5235
  hero_media: media.length ? media[0] : null,
5032
5236
  };
5033
5237
  }
5034
- _send(res, 200, renderCart({
5238
+ _send(res, 200, renderCart(Object.assign({
5035
5239
  lines: lines,
5036
5240
  totals: totals,
5037
5241
  product_lookup: productLookup,
5038
5242
  can_save: !!(deps.saveForLater && deps.customers),
5039
5243
  shop_name: shopName,
5040
5244
  theme: theme,
5041
- }));
5245
+ }, ccy)));
5042
5246
  });
5043
5247
 
5044
5248
  // ---- checkout flow -------------------------------------------------
@@ -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.4",
7
- "tag": "v0.13.4",
6
+ "version": "0.13.5",
7
+ "tag": "v0.13.5",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.5 (2026-05-26) — **`b.ai.aedtBiasAudit` — NYC Local Law 144 bias audit.** b.ai.aedtBiasAudit computes the bias-audit figures New York City Local Law 144 requires before an Automated Employment Decision Tool may screen candidates (NYC Admin. Code §20-870 et seq.; DCWP rules 6 RCNY §5-300). Given the per-category counts an independent auditor collected — selected/total for a pass-fail tool, or scored-above-the-overall-median/total for a continuous-score tool — it returns the selection (or scoring) rate, the impact ratio (each group's rate divided by the most-selected group's rate), and an adverse-impact flag (impact ratio below the EEOC four-fifths threshold of 0.8) for every group, across the sex, race/ethnicity, and intersectional dimensions, plus the most-selected group per dimension and an overall flag. Categories under 2% of the audited data are marked excluded per DCWP discretion. It is a pure calculation that produces exactly the figures the annual published summary must contain — the law mandates the calculation, not any particular remediation. The relevant compliance postures (nyc-ll144, and ca-tfaia for California SB 53) were already in the catalog. **Added:** *`b.ai.aedtBiasAudit` — Local Law 144 selection/scoring rates and four-fifths impact ratios* — `b.ai.aedtBiasAudit({ type, metadata, categories, minCategoryShare? })` where `type` is `"selection"` (group entries `{ selected, total }`) or `"scoring"` (`{ scoredAboveMedian, total }`). Returns per-group rate, impact ratio, and `adverseImpact` flag across the `sex`, `raceEthnicity`, and `intersectional` dimensions, plus the most-selected group per dimension and an `anyAdverseImpact` summary. Categories below `minCategoryShare` (2% default) are excluded from the impact-ratio basis. Throws `AedtBiasAuditError` on malformed input.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -189,6 +189,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
189
189
  - **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`)
190
190
  - **AI usage quotas** — per-tenant / per-model budgets metered by tokens / requests / cost-usd / compute-hours over calendar-aligned windows, with an atomic conditional reserve (no charge-then-refund race) + hard/soft/warn enforcement and an optional cross-node store; defends OWASP LLM10:2025 unbounded consumption / denial-of-wallet (`b.ai.quota`)
191
191
  - **AI capability routing** — model-capability registry (context window / modalities / tool use / reasoning tier / cost rates) + a router that picks the cheapest model satisfying a request's requirements, refusing capability mismatches before the inference call (NIST AI RMF MAP + Model Cards); composes with `b.ai.quota` cost budgets (`b.ai.capability`)
192
+ - **AEDT bias audit** — NYC Local Law 144 bias-audit figures (`b.ai.aedtBiasAudit`): selection / scoring rates and EEOC four-fifths-rule impact ratios across sex, race/ethnicity, and their intersection, with the most-selected group and adverse-impact flags (impact ratio < 0.8) for the annual published summary; sub-2% categories excludable per DCWP §5-301
192
193
  ### Compliance regimes
193
194
 
194
195
  - **Posture coordinator** — `b.compliance` cascades operator-declared regime into retention / audit / db / cryptoField via POSTURE_DEFAULTS:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.4",
4
- "createdAt": "2026-05-27T00:29:21.229Z",
3
+ "frameworkVersion": "0.13.5",
4
+ "createdAt": "2026-05-27T01:38:32.776Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -1388,6 +1388,10 @@
1388
1388
  }
1389
1389
  }
1390
1390
  },
1391
+ "aedtBiasAudit": {
1392
+ "type": "function",
1393
+ "arity": 1
1394
+ },
1391
1395
  "aiContentDetect": {
1392
1396
  "type": "object",
1393
1397
  "members": {
@@ -55,6 +55,7 @@ var KNOWN_POSTURES = {
55
55
  // v0.10.8 — EU AI Act Art. 50 + Art. 11 + AB-853 + CAC + AI governance
56
56
  "eu-ai-act-art-50": 1, "eu-ai-act-art-11": 1,
57
57
  "ca-ab-853": 1, "ca-sb-942": 1,
58
+ "nyc-ll144": 1, "ca-tfaia": 1,
58
59
  "cac-genai-label": 1,
59
60
  "nist-ai-600-1": 1, "nist-ai-rmf": 1,
60
61
  "iso-42001": 1, "iso-23894": 1,
@@ -477,6 +477,7 @@ module.exports = {
477
477
  quota: require("./lib/ai-quota"),
478
478
  capability: require("./lib/ai-capability"),
479
479
  dp: require("./lib/ai-dp"),
480
+ aedtBiasAudit: require("./lib/ai-aedt-bias-audit"),
480
481
  },
481
482
  promisePool: require("./lib/promise-pool"),
482
483
  sdNotify: require("./lib/sd-notify"),
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.ai.aedtBiasAudit
4
+ * @nav Compliance
5
+ * @title AEDT Bias Audit
6
+ *
7
+ * @intro
8
+ * Compute the bias-audit statistics New York City Local Law 144 requires
9
+ * before an Automated Employment Decision Tool (AEDT) may be used to screen
10
+ * candidates or employees. The law (NYC Admin. Code §20-870 et seq., in force
11
+ * since 2023-07-05; DCWP rules 6 RCNY §5-300 et seq.) requires an independent
12
+ * annual audit that reports, for each demographic category, the rate at which
13
+ * the tool selects (or scores above the median for) that group and the
14
+ * <em>impact ratio</em> — that group's rate divided by the rate of the
15
+ * most-selected group. An impact ratio below the four-fifths (0.8) threshold
16
+ * from the EEOC Uniform Guidelines flags potential adverse impact; the law
17
+ * requires the number to be calculated and published, not any particular
18
+ * remediation.
19
+ *
20
+ * The audit is computed across three dimensions: sex, race/ethnicity, and
21
+ * the intersection of the two, using the EEOC categories. Categories that
22
+ * make up less than 2% of the audited data may be excluded from the impact-
23
+ * ratio calculation at the auditor's discretion (DCWP §5-301). This primitive
24
+ * takes the per-category counts — selected/total for a pass-fail tool, or
25
+ * scored-above-the-overall-median/total for a continuous-score tool — and
26
+ * returns the selection (or scoring) rate, impact ratio, and adverse-impact
27
+ * flag per group, plus the most-selected group and an overall flag. It is a
28
+ * pure calculation: the operator supplies the data an independent auditor
29
+ * collected, and gets back the figures the published summary must contain.
30
+ *
31
+ * @card
32
+ * NYC Local Law 144 AEDT bias audit (`b.ai.aedtBiasAudit`) — selection /
33
+ * scoring rates and four-fifths-rule impact ratios across sex, race/ethnicity,
34
+ * and their intersection, with the most-selected group and adverse-impact
35
+ * flags for the published audit summary.
36
+ */
37
+
38
+ var validateOpts = require("./validate-opts");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var AedtBiasAuditError = defineClass("AedtBiasAuditError", { alwaysPermanent: true });
42
+
43
+ var FOUR_FIFTHS = 4 / 5; // EEOC Uniform Guidelines four-fifths adverse-impact threshold
44
+ var DEFAULT_MIN_SHARE = 0.02; // DCWP §5-301 — categories under 2% may be excluded
45
+ var DIMENSIONS = ["sex", "raceEthnicity", "intersectional"];
46
+
47
+ function _str(v, label) {
48
+ if (typeof v !== "string" || v.length === 0) throw new AedtBiasAuditError("aedt/bad-metadata", "aedtBiasAudit: metadata." + label + " must be a non-empty string");
49
+ return v;
50
+ }
51
+ function _count(v, label) {
52
+ if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: " + label + " must be a non-negative integer");
53
+ return v;
54
+ }
55
+
56
+ // Reduce one dimension's per-group counts to the LL-144 figures.
57
+ function _auditDimension(groups, type, minShare) {
58
+ var names = Object.keys(groups);
59
+ var rows = [];
60
+ var dimensionTotal = 0;
61
+ var numeratorKey = type === "scoring" ? "scoredAboveMedian" : "selected";
62
+
63
+ names.forEach(function (name) {
64
+ var g = groups[name];
65
+ if (!g || typeof g !== "object") throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: group '" + name + "' must be an object with " + numeratorKey + " + total");
66
+ var total = _count(g.total, name + ".total");
67
+ var num = _count(g[numeratorKey], name + "." + numeratorKey);
68
+ if (num > total) throw new AedtBiasAuditError("aedt/bad-count", "aedtBiasAudit: " + name + "." + numeratorKey + " (" + num + ") exceeds total (" + total + ")");
69
+ dimensionTotal += total;
70
+ rows.push({ category: name, total: total, _num: num, rate: total === 0 ? 0 : num / total });
71
+ });
72
+
73
+ // Exclude sub-threshold categories (auditor discretion), then find the
74
+ // most-selected group among those that remain.
75
+ var maxRate = 0;
76
+ var mostSelected = null;
77
+ rows.forEach(function (r) {
78
+ r.share = dimensionTotal === 0 ? 0 : r.total / dimensionTotal;
79
+ r.excluded = dimensionTotal > 0 && r.share < minShare;
80
+ if (!r.excluded && r.rate > maxRate) { maxRate = r.rate; mostSelected = r.category; }
81
+ });
82
+
83
+ rows.forEach(function (r) {
84
+ r.impactRatio = (r.excluded || maxRate === 0) ? null : r.rate / maxRate;
85
+ r.adverseImpact = r.impactRatio !== null && r.impactRatio < FOUR_FIFTHS;
86
+ delete r._num;
87
+ });
88
+ // Stable order: highest rate first, then category name.
89
+ rows.sort(function (a, b) { return b.rate - a.rate || (a.category < b.category ? -1 : a.category > b.category ? 1 : 0); });
90
+ return { rows: rows, mostSelected: mostSelected };
91
+ }
92
+
93
+ /**
94
+ * @primitive b.ai.aedtBiasAudit
95
+ * @signature b.ai.aedtBiasAudit(opts)
96
+ * @since 0.13.5
97
+ * @status stable
98
+ * @compliance nyc-ll144, soc2
99
+ * @related b.ai.disclosure.applyAll, b.ai.disclosure.chatbot
100
+ *
101
+ * Compute the NYC Local Law 144 bias-audit figures from per-category counts.
102
+ * <code>type</code> is <code>"selection"</code> for a pass-fail tool (each
103
+ * group entry is <code>{ selected, total }</code>) or <code>"scoring"</code>
104
+ * for a continuous-score tool (<code>{ scoredAboveMedian, total }</code>, where
105
+ * the count is candidates scoring above the <em>overall</em> median). Returns
106
+ * the selection/scoring rate, impact ratio (group rate ÷ most-selected group's
107
+ * rate), and an <code>adverseImpact</code> flag (impact ratio &lt; 0.8) per
108
+ * group, across the <code>sex</code>, <code>raceEthnicity</code>, and
109
+ * <code>intersectional</code> dimensions, plus the most-selected group per
110
+ * dimension. Categories under <code>minCategoryShare</code> (2% by default) are
111
+ * marked <code>excluded</code> and left out of the impact-ratio basis. Throws
112
+ * <code>AedtBiasAuditError</code> on malformed input. The result is the data an
113
+ * employer must publish; the law mandates the calculation, not any remediation.
114
+ *
115
+ * @opts
116
+ * type: string, // "selection" | "scoring" (required)
117
+ * metadata: object, // { tool, auditor, auditDate, distributionDate? } (tool/auditor/auditDate required)
118
+ * categories: object, // { sex?, raceEthnicity?, intersectional? } → { <group>: { selected|scoredAboveMedian, total } }
119
+ * minCategoryShare: number, // default: 0.02 (DCWP §5-301 — sub-2% categories may be excluded)
120
+ *
121
+ * @example
122
+ * var report = b.ai.aedtBiasAudit({
123
+ * type: "selection",
124
+ * metadata: { tool: "ResumeRanker v3", auditor: "Acme Audit LLC", auditDate: "2026-05-26" },
125
+ * categories: { sex: { Male: { selected: 60, total: 100 }, Female: { selected: 42, total: 100 } } },
126
+ * });
127
+ * report.results.sex[1].impactRatio; // → 0.7 (Female: 42% / 60%)
128
+ * report.results.sex[1].adverseImpact; // → true (below the 0.8 four-fifths threshold)
129
+ */
130
+ function aedtBiasAudit(opts) {
131
+ opts = opts || {};
132
+ // Surface an unknown/typoed option as this primitive's own error type rather
133
+ // than the generic Error validateOpts throws, so the malformed-input contract
134
+ // (AedtBiasAuditError / e.code) holds for every bad-config path.
135
+ try { validateOpts(opts, ["type", "metadata", "categories", "minCategoryShare"], "aedtBiasAudit"); }
136
+ catch (e) { throw new AedtBiasAuditError("aedt/bad-opts", e && e.message || "aedtBiasAudit: invalid options"); }
137
+ if (opts.type !== "selection" && opts.type !== "scoring") throw new AedtBiasAuditError("aedt/bad-type", "aedtBiasAudit: type must be 'selection' or 'scoring'");
138
+
139
+ var md = opts.metadata || {};
140
+ var metadata = {
141
+ tool: _str(md.tool, "tool"),
142
+ auditor: _str(md.auditor, "auditor"),
143
+ auditDate: _str(md.auditDate, "auditDate"),
144
+ distributionDate: md.distributionDate != null ? _str(md.distributionDate, "distributionDate") : null,
145
+ };
146
+
147
+ var minShare = opts.minCategoryShare != null ? opts.minCategoryShare : DEFAULT_MIN_SHARE;
148
+ if (typeof minShare !== "number" || !isFinite(minShare) || minShare < 0 || minShare >= 1) throw new AedtBiasAuditError("aedt/bad-share", "aedtBiasAudit: minCategoryShare must be a number in [0, 1)");
149
+
150
+ var cats = opts.categories || {};
151
+ var present = DIMENSIONS.filter(function (d) { return cats[d] && typeof cats[d] === "object" && Object.keys(cats[d]).length > 0; });
152
+ if (present.length === 0) throw new AedtBiasAuditError("aedt/no-categories", "aedtBiasAudit: at least one of sex / raceEthnicity / intersectional must carry group counts");
153
+
154
+ var results = {};
155
+ var mostSelected = {};
156
+ var adverseImpactGroups = [];
157
+ present.forEach(function (dim) {
158
+ var out = _auditDimension(cats[dim], opts.type, minShare);
159
+ results[dim] = out.rows;
160
+ mostSelected[dim] = out.mostSelected;
161
+ out.rows.forEach(function (r) { if (r.adverseImpact) adverseImpactGroups.push({ dimension: dim, category: r.category, impactRatio: r.impactRatio }); });
162
+ });
163
+
164
+ return {
165
+ type: opts.type,
166
+ metadata: metadata,
167
+ results: results,
168
+ summary: {
169
+ mostSelected: mostSelected,
170
+ adverseImpactGroups: adverseImpactGroups,
171
+ anyAdverseImpact: adverseImpactGroups.length > 0,
172
+ fourFifthsThreshold: FOUR_FIFTHS,
173
+ },
174
+ };
175
+ }
176
+
177
+ aedtBiasAudit.FOUR_FIFTHS = FOUR_FIFTHS;
178
+ aedtBiasAudit.AedtBiasAuditError = AedtBiasAuditError;
179
+
180
+ module.exports = aedtBiasAudit;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.5",
4
+ "date": "2026-05-26",
5
+ "headline": "`b.ai.aedtBiasAudit` — NYC Local Law 144 bias audit",
6
+ "summary": "b.ai.aedtBiasAudit computes the bias-audit figures New York City Local Law 144 requires before an Automated Employment Decision Tool may screen candidates (NYC Admin. Code §20-870 et seq.; DCWP rules 6 RCNY §5-300). Given the per-category counts an independent auditor collected — selected/total for a pass-fail tool, or scored-above-the-overall-median/total for a continuous-score tool — it returns the selection (or scoring) rate, the impact ratio (each group's rate divided by the most-selected group's rate), and an adverse-impact flag (impact ratio below the EEOC four-fifths threshold of 0.8) for every group, across the sex, race/ethnicity, and intersectional dimensions, plus the most-selected group per dimension and an overall flag. Categories under 2% of the audited data are marked excluded per DCWP discretion. It is a pure calculation that produces exactly the figures the annual published summary must contain — the law mandates the calculation, not any particular remediation. The relevant compliance postures (nyc-ll144, and ca-tfaia for California SB 53) were already in the catalog.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.ai.aedtBiasAudit` — Local Law 144 selection/scoring rates and four-fifths impact ratios",
13
+ "body": "`b.ai.aedtBiasAudit({ type, metadata, categories, minCategoryShare? })` where `type` is `\"selection\"` (group entries `{ selected, total }`) or `\"scoring\"` (`{ scoredAboveMedian, total }`). Returns per-group rate, impact ratio, and `adverseImpact` flag across the `sex`, `raceEthnicity`, and `intersectional` dimensions, plus the most-selected group per dimension and an `anyAdverseImpact` summary. Categories below `minCategoryShare` (2% default) are excluded from the impact-ratio basis. Throws `AedtBiasAuditError` on malformed input."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.ai.aedtBiasAudit (NYC Local Law 144 AEDT bias audit).
4
+ *
5
+ * Oracle: the EEOC four-fifths rule and the DCWP §5-300 impact-ratio formula.
6
+ * The canonical worked example — a tool passing 60% of men and 42% of women —
7
+ * yields an impact ratio of 42/60 = 0.70 for women, below the 0.8 threshold.
8
+ * Boundary, scoring-rate, intersectional, sub-2% exclusion, and the
9
+ * most-selected basis are each asserted against the formula.
10
+ */
11
+
12
+ var b = require("../../index");
13
+ var helpers = require("../helpers");
14
+ var check = helpers.check;
15
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
16
+ function row(report, dim, cat) { return report.results[dim].filter(function (r) { return r.category === cat; })[0]; }
17
+
18
+ var MD = { tool: "ResumeRanker v3", auditor: "Acme Audit LLC", auditDate: "2026-05-26" };
19
+
20
+ function testSurface() {
21
+ check("b.ai.aedtBiasAudit is a function", typeof b.ai.aedtBiasAudit === "function");
22
+ check("b.ai.aedtBiasAudit.FOUR_FIFTHS is 0.8", b.ai.aedtBiasAudit.FOUR_FIFTHS === 0.8);
23
+ check("b.ai.aedtBiasAudit.AedtBiasAuditError is a class", typeof b.ai.aedtBiasAudit.AedtBiasAuditError === "function");
24
+ }
25
+
26
+ function testFourFifthsWorkedExample() {
27
+ var r = b.ai.aedtBiasAudit({
28
+ type: "selection",
29
+ metadata: MD,
30
+ categories: { sex: { Male: { selected: 60, total: 100 }, Female: { selected: 42, total: 100 } } },
31
+ });
32
+ var f = row(r, "sex", "Female"), m = row(r, "sex", "Male");
33
+ check("Female selection rate 0.42", f.rate === 0.42);
34
+ check("Female impact ratio 0.70 (42/60)", Math.abs(f.impactRatio - 0.7) < 1e-9);
35
+ check("Female flagged adverse (< 0.8)", f.adverseImpact === true);
36
+ check("Male is the most-selected (ratio 1)", m.impactRatio === 1 && m.adverseImpact === false);
37
+ check("most-selected sex group is Male", r.summary.mostSelected.sex === "Male");
38
+ check("anyAdverseImpact true", r.summary.anyAdverseImpact === true);
39
+ check("adverseImpactGroups names Female", r.summary.adverseImpactGroups.length === 1 && r.summary.adverseImpactGroups[0].category === "Female");
40
+ check("metadata echoed", r.metadata.tool === MD.tool && r.metadata.auditor === MD.auditor && r.metadata.distributionDate === null);
41
+ }
42
+
43
+ function testFourFifthsBoundary() {
44
+ // Exactly 0.80 is NOT adverse (strictly-less-than threshold); 0.79 is.
45
+ var at = b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { X: { selected: 100, total: 100 }, Y: { selected: 80, total: 100 } } } });
46
+ check("impact ratio exactly 0.80 is not adverse", row(at, "sex", "Y").adverseImpact === false);
47
+ var below = b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { X: { selected: 100, total: 100 }, Y: { selected: 79, total: 100 } } } });
48
+ check("impact ratio 0.79 is adverse", row(below, "sex", "Y").adverseImpact === true);
49
+ }
50
+
51
+ function testScoringRate() {
52
+ // Scoring tools: rate = scored-above-overall-median / total.
53
+ var r = b.ai.aedtBiasAudit({ type: "scoring", metadata: MD, categories: { sex: { M: { scoredAboveMedian: 50, total: 100 }, F: { scoredAboveMedian: 30, total: 100 } } } });
54
+ check("scoring rate F 0.30", row(r, "sex", "F").rate === 0.3);
55
+ check("scoring impact ratio F 0.60 (30/50)", Math.abs(row(r, "sex", "F").impactRatio - 0.6) < 1e-9);
56
+ }
57
+
58
+ function testIntersectionalAndRace() {
59
+ var r = b.ai.aedtBiasAudit({
60
+ type: "selection",
61
+ metadata: MD,
62
+ categories: {
63
+ raceEthnicity: { "White": { selected: 80, total: 100 }, "Black or African American": { selected: 48, total: 100 } },
64
+ intersectional: { "Male - White": { selected: 90, total: 100 }, "Female - Black or African American": { selected: 45, total: 100 } },
65
+ },
66
+ });
67
+ check("race dimension computed", row(r, "raceEthnicity", "Black or African American").impactRatio === 0.6);
68
+ check("intersectional dimension computed", row(r, "intersectional", "Female - Black or African American").impactRatio === 0.5);
69
+ check("intersectional adverse flagged", row(r, "intersectional", "Female - Black or African American").adverseImpact === true);
70
+ }
71
+
72
+ function testSubTwoPercentExclusion() {
73
+ var r = b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { Big: { selected: 90, total: 1000 }, Tiny: { selected: 0, total: 10 } } } });
74
+ check("sub-2% category excluded", row(r, "sex", "Tiny").excluded === true);
75
+ check("excluded category has null impact ratio", row(r, "sex", "Tiny").impactRatio === null);
76
+ check("excluded category not flagged adverse", row(r, "sex", "Tiny").adverseImpact === false);
77
+ // A higher minCategoryShare excludes more.
78
+ var r2 = b.ai.aedtBiasAudit({ type: "selection", metadata: MD, minCategoryShare: 0, categories: { sex: { Big: { selected: 90, total: 1000 }, Tiny: { selected: 0, total: 10 } } } });
79
+ check("minCategoryShare 0 includes Tiny", row(r2, "sex", "Tiny").excluded === false);
80
+ }
81
+
82
+ function testErrors() {
83
+ check("bad type throws", code(function () { b.ai.aedtBiasAudit({ type: "x", metadata: MD, categories: { sex: { M: { selected: 1, total: 1 } } } }); }) === "aedt/bad-type");
84
+ check("missing metadata throws", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: {}, categories: { sex: { M: { selected: 1, total: 1 } } } }); }) === "aedt/bad-metadata");
85
+ check("no categories throws", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: {} }); }) === "aedt/no-categories");
86
+ check("selected > total throws", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { M: { selected: 5, total: 2 } } } }); }) === "aedt/bad-count");
87
+ check("negative count throws", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { M: { selected: -1, total: 2 } } } }); }) === "aedt/bad-count");
88
+ check("bad minCategoryShare throws", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, minCategoryShare: 1, categories: { sex: { M: { selected: 1, total: 1 } } } }); }) === "aedt/bad-share");
89
+ // A typoed/unknown option must surface as AedtBiasAuditError (not a generic
90
+ // Error from validateOpts), so e.code / instanceof handling is reliable.
91
+ check("unknown opt throws aedt/bad-opts", code(function () { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { M: { selected: 1, total: 1 } } }, minCategoryshare: 0.01 }); }) === "aedt/bad-opts");
92
+ var threw = null;
93
+ try { b.ai.aedtBiasAudit({ type: "selection", metadata: MD, categories: { sex: { M: { selected: 1, total: 1 } } }, bogus: 1 }); } catch (e) { threw = e; }
94
+ check("unknown opt error is an AedtBiasAuditError", threw instanceof b.ai.aedtBiasAudit.AedtBiasAuditError);
95
+ }
96
+
97
+ async function run() {
98
+ testSurface();
99
+ testFourFifthsWorkedExample();
100
+ testFourFifthsBoundary();
101
+ testScoringRate();
102
+ testIntersectionalAndRace();
103
+ testSubTwoPercentExclusion();
104
+ testErrors();
105
+ }
106
+ module.exports = { run: run };
107
+ if (require.main === module) { run().then(function () { console.log("[ai-aedt-bias-audit] OK — " + helpers.getChecks() + " checks passed"); }, function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {