@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 +2 -0
- package/README.md +1 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/currency-display.js +201 -4
- package/lib/storefront.js +240 -36
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +1 -0
- package/lib/vendor/blamejs/lib/ai-aedt-bias-audit.js +180 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.5.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-aedt-bias-audit.test.js +107 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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. |
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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",
|
package/lib/currency-display.js
CHANGED
|
@@ -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:
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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>© {{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
|
-
?
|
|
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
|
-
?
|
|
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 ?
|
|
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:
|
|
3313
|
-
total:
|
|
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 =
|
|
3321
|
-
var total =
|
|
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:
|
|
4304
|
-
amount_str:
|
|
4305
|
-
discount_str: discountMinor > 0 ?
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 -------------------------------------------------
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.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
|
-
"createdAt": "2026-05-
|
|
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 < 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;
|
|
@@ -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