@blamejs/blamejs-shop 0.1.36 → 0.1.37
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 +1 -1
- package/lib/storefront.js +372 -44
- package/lib/translations.js +203 -0
- 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/index.js +1 -0
- package/lib/vendor/blamejs/lib/ai-frontier-protocol.js +196 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.6.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-frontier-protocol.test.js +83 -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.37 (2026-05-26) — **Localized storefront — the UI chrome renders in the visitor's language, with a locale switcher and right-to-left support.** The storefront's interface chrome — the navigation, search controls, newsletter band, and footer — now renders in the visitor's language. The locale is resolved the same way at the edge and in the container: an explicit `?lang=` choice first, then a saved preference cookie, then the browser's Accept-Language header, then the operator's default. A locale switcher in the footer lets the visitor change it; the choice persists in a first-party cookie. Right-to-left languages (Arabic, Hebrew, Persian, …) flip the document direction automatically. Strings come from the operator's locale policy and translation rows, and any key without a translation falls back to the English baseline rather than showing a raw placeholder — so a partially translated catalog never renders broken text. The default locale renders at the edge for cookie-less visitors (so cached pages stay fast); a non-default choice is served by the container. Everything works with JavaScript off, and the localized markup is byte-identical across the edge and the container. **Added:** *Localized UI chrome + footer locale switcher* — Navigation, search labels, the newsletter band, and the footer render in the visitor's locale. A locale switcher in the footer lists the languages the operator has enabled (shown by their own names — "Deutsch", "Français"); selecting one persists the choice in a first-party cookie and returns the visitor to the page they were on. The locale resolves identically at the edge and in the container — `?lang=`, then the cookie, then `Accept-Language`, then the operator default — and a right-to-left language sets the document's `dir` so the layout mirrors correctly. The switcher and selection work without JavaScript. · *Operator locale policy with English-baseline fallback* — Enable languages by setting a locale policy (a default locale plus the supported list) and supplying translation rows for the chrome strings; the storefront resolves each string against the active policy, layered over the operator's translations, and falls back to the built-in English baseline for any key still missing — never a raw key or empty label. With no policy seeded the storefront renders the English baseline and shows no switcher, so the feature is opt-in and a fresh install is unaffected. `SHOP_DEFAULT_LOCALE` sets the default the edge serves to cookie-less visitors; set `SHOP_LOCALES` (the supported list) so the edge forwards a first-time visitor whose `Accept-Language` prefers a supported non-default locale to the container instead of caching the default. An explicit cookie or `?lang=` choice is always served by the container so edge caching stays simple.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
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
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. |
|
|
85
|
+
| **`lib/translations.js`** | Storefront localization. The UI chrome (nav, search, newsletter band, footer) renders in the visitor's locale, resolved identically at the edge and container: `?lang=`, then a first-party cookie, then `Accept-Language`, then the operator default. A footer locale switcher (languages shown by their autonyms) persists the choice and 303s back; `GET /locale` sets the cookie. Right-to-left languages set the document `dir`. Strings layer the operator's translation rows over a built-in English baseline — a missing key falls back to English, never a raw placeholder. Enable it by seeding a locale policy (default + supported locales) via `localeRouter`; with none seeded the storefront renders the English baseline and shows no switcher. `SHOP_DEFAULT_LOCALE` sets the edge default and `SHOP_LOCALES` (the supported list) lets the edge forward an `Accept-Language`-preferred non-default locale to the container instead of caching the default; an explicit cookie/`?lang=` choice is always container-served. Server-rendered (works with JS off), byte-identical edge/container. |
|
|
85
86
|
| **`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). |
|
|
86
87
|
| **`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). |
|
|
87
88
|
| **`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
package/lib/storefront.js
CHANGED
|
@@ -28,9 +28,22 @@
|
|
|
28
28
|
var emailModule = require("./email");
|
|
29
29
|
var pricing = require("./pricing");
|
|
30
30
|
var currencyDisplayModule = require("./currency-display");
|
|
31
|
+
var translationsModule = require("./translations");
|
|
32
|
+
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
31
33
|
|
|
32
34
|
var b = require("./vendor/blamejs");
|
|
33
35
|
|
|
36
|
+
// Per-request locale context store. The storefront's locale middleware
|
|
37
|
+
// resolves the request's locale + chrome strings once and seeds this
|
|
38
|
+
// store; every `_wrap` call on the same request reads the resolved
|
|
39
|
+
// context back out without the ~30 renderers having to thread it through
|
|
40
|
+
// their opts. `enterWith` scopes the value to the request's async
|
|
41
|
+
// execution context (each inbound request runs its own `handle()` chain),
|
|
42
|
+
// so concurrent requests never see each other's locale. A renderer
|
|
43
|
+
// reached outside a request (a unit test calling `renderHome` directly)
|
|
44
|
+
// finds no store and falls back to the English baseline.
|
|
45
|
+
var _localeAls = new AsyncLocalStorage();
|
|
46
|
+
|
|
34
47
|
// Payment-webhook signatures (Stripe's HMAC, PayPal's, …) are computed over
|
|
35
48
|
// the EXACT raw request bytes, but the global JSON body-parser reparses and
|
|
36
49
|
// discards them. This middleware buffers the raw body for the given POST
|
|
@@ -126,7 +139,7 @@ var CONSENT_BANNER =
|
|
|
126
139
|
|
|
127
140
|
var LAYOUT =
|
|
128
141
|
"<!DOCTYPE html>\n" +
|
|
129
|
-
"<html lang=\"
|
|
142
|
+
"<html lang=\"{{lang}}\" dir=\"{{dir}}\">\n" +
|
|
130
143
|
"<head>\n" +
|
|
131
144
|
" <meta charset=\"utf-8\">\n" +
|
|
132
145
|
" <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
|
|
@@ -146,13 +159,13 @@ var LAYOUT =
|
|
|
146
159
|
" <meta name=\"twitter:image\" content=\"{{og_image}}\">\n" +
|
|
147
160
|
"</head>\n" +
|
|
148
161
|
"<body>\n" +
|
|
149
|
-
" <a class=\"skip-link\" href=\"#main\">
|
|
162
|
+
" <a class=\"skip-link\" href=\"#main\">{{skip_to_content}}</a>\n" +
|
|
150
163
|
"\n" +
|
|
151
164
|
" <div class=\"utility-bar\" role=\"complementary\">\n" +
|
|
152
165
|
" <div class=\"utility-bar__inner\">\n" +
|
|
153
|
-
" <span class=\"utility-bar__pill\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span>
|
|
154
|
-
" <span class=\"utility-bar__msg\">
|
|
155
|
-
" <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">
|
|
166
|
+
" <span class=\"utility-bar__pill\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> {{util_pill}}</span>\n" +
|
|
167
|
+
" <span class=\"utility-bar__msg\">{{util_msg}}</span>\n" +
|
|
168
|
+
" <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">{{util_star}}</a>\n" +
|
|
156
169
|
" </div>\n" +
|
|
157
170
|
" </div>\n" +
|
|
158
171
|
"\n" +
|
|
@@ -161,17 +174,17 @@ var LAYOUT =
|
|
|
161
174
|
" <a href=\"/\" class=\"brand\" aria-label=\"{{shop_name}}\"><img src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\"></a>\n" +
|
|
162
175
|
" <form class=\"site-search\" action=\"/search\" method=\"get\" role=\"search\">\n" +
|
|
163
176
|
" <div class=\"site-search__inner\">\n" +
|
|
164
|
-
" <label for=\"site-search-q\" class=\"skip-link\">
|
|
177
|
+
" <label for=\"site-search-q\" class=\"skip-link\">{{search_label}}</label>\n" +
|
|
165
178
|
" <svg class=\"site-search__icon\" viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M21 21l-4.35-4.35M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>\n" +
|
|
166
|
-
" <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"
|
|
167
|
-
" <button type=\"submit\">
|
|
179
|
+
" <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"{{search_placeholder}}\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\">\n" +
|
|
180
|
+
" <button type=\"submit\">{{search_submit}}</button>\n" +
|
|
168
181
|
" </div>\n" +
|
|
169
182
|
" </form>\n" +
|
|
170
183
|
" <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
|
|
171
|
-
" <a class=\"site-nav__link\" href=\"/\">
|
|
172
|
-
" <a class=\"site-nav__link\" href=\"#framework\">
|
|
173
|
-
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"
|
|
174
|
-
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"
|
|
184
|
+
" <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
|
|
185
|
+
" <a class=\"site-nav__link\" href=\"#framework\">{{nav_framework}}</a>\n" +
|
|
186
|
+
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
|
|
187
|
+
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
|
|
175
188
|
" </nav>\n" +
|
|
176
189
|
" </div>\n" +
|
|
177
190
|
" </header>\n" +
|
|
@@ -181,14 +194,14 @@ var LAYOUT =
|
|
|
181
194
|
" <section class=\"newsletter-band\" aria-labelledby=\"newsletter-title\">\n" +
|
|
182
195
|
" <div class=\"newsletter-band__inner\">\n" +
|
|
183
196
|
" <div class=\"newsletter-band__copy\">\n" +
|
|
184
|
-
" <p class=\"eyebrow eyebrow--on-dark\">
|
|
185
|
-
" <h2 id=\"newsletter-title\">
|
|
186
|
-
" <p class=\"newsletter-band__lede\">
|
|
197
|
+
" <p class=\"eyebrow eyebrow--on-dark\">{{newsletter_eyebrow}}</p>\n" +
|
|
198
|
+
" <h2 id=\"newsletter-title\">{{newsletter_title}}</h2>\n" +
|
|
199
|
+
" <p class=\"newsletter-band__lede\">{{newsletter_lede}}</p>\n" +
|
|
187
200
|
" </div>\n" +
|
|
188
201
|
" <form class=\"newsletter-band__form\" method=\"post\" action=\"/newsletter\">\n" +
|
|
189
|
-
" <label class=\"skip-link\" for=\"newsletter-email\">
|
|
202
|
+
" <label class=\"skip-link\" for=\"newsletter-email\">{{newsletter_email}}</label>\n" +
|
|
190
203
|
" <input id=\"newsletter-email\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">\n" +
|
|
191
|
-
" <button type=\"submit\">
|
|
204
|
+
" <button type=\"submit\">{{newsletter_submit}}</button>\n" +
|
|
192
205
|
" </form>\n" +
|
|
193
206
|
" </div>\n" +
|
|
194
207
|
" </section>\n" +
|
|
@@ -197,7 +210,7 @@ var LAYOUT =
|
|
|
197
210
|
" <div class=\"site-footer__inner\">\n" +
|
|
198
211
|
" <div class=\"site-footer__brand-col\">\n" +
|
|
199
212
|
" <img class=\"site-footer__logo\" src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\">\n" +
|
|
200
|
-
" <p class=\"site-footer__tagline\">
|
|
213
|
+
" <p class=\"site-footer__tagline\">{{footer_tagline}}</p>\n" +
|
|
201
214
|
" <ul class=\"site-footer__social\" aria-label=\"Project links\">\n" +
|
|
202
215
|
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\" aria-label=\"GitHub\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.3v3.4c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5Z\" fill=\"currentColor\"/></svg></a></li>\n" +
|
|
203
216
|
" <li><a href=\"https://npmjs.com/package/blamejs\" rel=\"noopener\" aria-label=\"npm\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M2 7v10h6v-7h3v7h11V7H2Zm15 8h-2v-5h-3v5h-1V9h6v6Z\" fill=\"currentColor\"/></svg></a></li>\n" +
|
|
@@ -205,44 +218,45 @@ var LAYOUT =
|
|
|
205
218
|
" </ul>\n" +
|
|
206
219
|
" </div>\n" +
|
|
207
220
|
" <div class=\"site-footer__col\">\n" +
|
|
208
|
-
" <h4>
|
|
221
|
+
" <h4>{{footer_shop_heading}}</h4>\n" +
|
|
209
222
|
" <ul>\n" +
|
|
210
|
-
" <li><a href=\"/\">
|
|
211
|
-
" <li><a href=\"/collections\">
|
|
212
|
-
" <li><a href=\"/categories\">
|
|
213
|
-
" <li><a href=\"/?sort=new\">
|
|
214
|
-
" <li><a href=\"/?sort=sale\">
|
|
215
|
-
" <li><a href=\"/compare\">
|
|
216
|
-
" <li><a href=\"/cart\">
|
|
223
|
+
" <li><a href=\"/\">{{footer_shop_all}}</a></li>\n" +
|
|
224
|
+
" <li><a href=\"/collections\">{{footer_shop_collections}}</a></li>\n" +
|
|
225
|
+
" <li><a href=\"/categories\">{{footer_shop_categories}}</a></li>\n" +
|
|
226
|
+
" <li><a href=\"/?sort=new\">{{footer_shop_new}}</a></li>\n" +
|
|
227
|
+
" <li><a href=\"/?sort=sale\">{{footer_shop_sale}}</a></li>\n" +
|
|
228
|
+
" <li><a href=\"/compare\">{{footer_shop_compare}}</a></li>\n" +
|
|
229
|
+
" <li><a href=\"/cart\">{{footer_shop_cart}}</a></li>\n" +
|
|
217
230
|
" </ul>\n" +
|
|
218
231
|
" </div>\n" +
|
|
219
232
|
" <div class=\"site-footer__col\">\n" +
|
|
220
|
-
" <h4>
|
|
233
|
+
" <h4>{{footer_framework_heading}}</h4>\n" +
|
|
221
234
|
" <ul>\n" +
|
|
222
|
-
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">
|
|
223
|
-
" <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">
|
|
224
|
-
" <li><a href=\"/SECURITY.md\">
|
|
225
|
-
" <li><a href=\"/CHANGELOG.md\">
|
|
235
|
+
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">{{footer_framework_source}}</a></li>\n" +
|
|
236
|
+
" <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">{{footer_framework_core}}</a></li>\n" +
|
|
237
|
+
" <li><a href=\"/SECURITY.md\">{{footer_framework_security}}</a></li>\n" +
|
|
238
|
+
" <li><a href=\"/CHANGELOG.md\">{{footer_framework_changelog}}</a></li>\n" +
|
|
226
239
|
" </ul>\n" +
|
|
227
240
|
" </div>\n" +
|
|
228
241
|
" <div class=\"site-footer__col\">\n" +
|
|
229
|
-
" <h4>
|
|
242
|
+
" <h4>{{footer_operators_heading}}</h4>\n" +
|
|
230
243
|
" <ul>\n" +
|
|
231
|
-
" <li><a href=\"/account\">
|
|
232
|
-
" <li><a href=\"/orders\">
|
|
233
|
-
" <li><a href=\"/admin\">
|
|
234
|
-
" <li><a href=\"mailto:hello@blamejs.shop\">
|
|
244
|
+
" <li><a href=\"/account\">{{footer_operators_account}}</a></li>\n" +
|
|
245
|
+
" <li><a href=\"/orders\">{{footer_operators_orders}}</a></li>\n" +
|
|
246
|
+
" <li><a href=\"/admin\">{{footer_operators_admin}}</a></li>\n" +
|
|
247
|
+
" <li><a href=\"mailto:hello@blamejs.shop\">{{footer_operators_contact}}</a></li>\n" +
|
|
235
248
|
" </ul>\n" +
|
|
236
249
|
" </div>\n" +
|
|
237
250
|
" </div>\n" +
|
|
238
251
|
" RAW_CURRENCY_SWITCHER\n" +
|
|
252
|
+
" RAW_LOCALE_SWITCHER\n" +
|
|
239
253
|
" <div class=\"site-footer__copy\">\n" +
|
|
240
|
-
" <p>© {{year}} {{shop_name}} —
|
|
254
|
+
" <p>© {{year}} {{shop_name}} — {{footer_copy_suffix}}</p>\n" +
|
|
241
255
|
" <ul>\n" +
|
|
242
|
-
" <li><a href=\"/SECURITY.md\">
|
|
243
|
-
" <li><a href=\"/privacy\">
|
|
244
|
-
" <li><a href=\"/terms\">
|
|
245
|
-
" <li><a href=\"/cookies\">
|
|
256
|
+
" <li><a href=\"/SECURITY.md\">{{footer_legal_security}}</a></li>\n" +
|
|
257
|
+
" <li><a href=\"/privacy\">{{footer_legal_privacy}}</a></li>\n" +
|
|
258
|
+
" <li><a href=\"/terms\">{{footer_legal_terms}}</a></li>\n" +
|
|
259
|
+
" <li><a href=\"/cookies\">{{footer_legal_cookies}}</a></li>\n" +
|
|
246
260
|
" </ul>\n" +
|
|
247
261
|
" </div>\n" +
|
|
248
262
|
" </footer>\n" +
|
|
@@ -374,6 +388,20 @@ function _currencyWrapOpts(opts) {
|
|
|
374
388
|
};
|
|
375
389
|
}
|
|
376
390
|
|
|
391
|
+
// Default locale context — the English chrome baseline, `lang="en"`,
|
|
392
|
+
// `dir="ltr"`, no switcher. Any page rendered without a resolved
|
|
393
|
+
// `locale_ctx` (an internal renderer that doesn't thread one, a deploy
|
|
394
|
+
// with no locale policy) falls back to this, so the chrome is always
|
|
395
|
+
// complete English — never a raw `{{key}}` and never a 500. The cart
|
|
396
|
+
// aria carries a literal `{count}` here because the baseline isn't tied
|
|
397
|
+
// to a request; `_wrap` interpolates the real count below.
|
|
398
|
+
var _DEFAULT_LOCALE_CTX = {
|
|
399
|
+
lang: "en",
|
|
400
|
+
dir: "ltr",
|
|
401
|
+
chrome: translationsModule.chromeDefaults(),
|
|
402
|
+
switcher_html: "",
|
|
403
|
+
};
|
|
404
|
+
|
|
377
405
|
function _wrap(opts) {
|
|
378
406
|
var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
|
|
379
407
|
? opts.theme_css
|
|
@@ -401,10 +429,30 @@ function _wrap(opts) {
|
|
|
401
429
|
note: opts.currency_note,
|
|
402
430
|
redirect_to: opts.currency_redirect_to,
|
|
403
431
|
});
|
|
404
|
-
|
|
432
|
+
|
|
433
|
+
// Locale context — resolved per request by the storefront's locale
|
|
434
|
+
// middleware and read back from the async-local store; an explicit
|
|
435
|
+
// `opts.locale_ctx` (a renderer that threads its own) takes precedence.
|
|
436
|
+
// Absent both, the English baseline applies. The chrome strings are
|
|
437
|
+
// HTML-escaped by `_render` per substitution like every other
|
|
438
|
+
// placeholder; the switcher form is raw HTML (already escaped at its
|
|
439
|
+
// own build site) so it's spliced post-render like the body.
|
|
440
|
+
var storeCtx = _localeAls.getStore();
|
|
441
|
+
var localeCtx = (opts.locale_ctx && opts.locale_ctx.chrome)
|
|
442
|
+
? opts.locale_ctx
|
|
443
|
+
: ((storeCtx && storeCtx.chrome) ? storeCtx : _DEFAULT_LOCALE_CTX);
|
|
444
|
+
var chrome = localeCtx.chrome;
|
|
445
|
+
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
446
|
+
// The cart aria-label carries the count: when the resolved string is
|
|
447
|
+
// the request-localised one it already has the count baked in; the
|
|
448
|
+
// English baseline default ships a literal `{count}` we fill here so
|
|
449
|
+
// the un-threaded fallback path still reads naturally.
|
|
450
|
+
var cartAria = String(chrome.nav_cart_aria).replace("{count}", String(cartCount));
|
|
451
|
+
|
|
452
|
+
var vars = {
|
|
405
453
|
title: opts.title,
|
|
406
454
|
shop_name: shopName,
|
|
407
|
-
cart_count:
|
|
455
|
+
cart_count: cartCount,
|
|
408
456
|
year: _COPYRIGHT_YEAR,
|
|
409
457
|
search_q: opts.search_q == null ? "" : opts.search_q,
|
|
410
458
|
theme_css: themeCss,
|
|
@@ -413,10 +461,29 @@ function _wrap(opts) {
|
|
|
413
461
|
og_description: ogDescription,
|
|
414
462
|
og_image: ogImage,
|
|
415
463
|
og_url: ogUrl,
|
|
464
|
+
lang: localeCtx.lang || "en",
|
|
465
|
+
dir: localeCtx.dir || "ltr",
|
|
466
|
+
nav_cart_aria: cartAria,
|
|
416
467
|
body: "RAW_BODY_PLACEHOLDER",
|
|
417
|
-
}
|
|
468
|
+
};
|
|
469
|
+
// Layer in every chrome string the LAYOUT references. `nav_cart_aria`
|
|
470
|
+
// is handled above (it needs the count interpolated); the two
|
|
471
|
+
// `locale_switcher_*` strings live only inside the switcher form
|
|
472
|
+
// (spliced via RAW_LOCALE_SWITCHER), not in the LAYOUT body — the
|
|
473
|
+
// strict `_render` refuses an unused placeholder, so they're skipped
|
|
474
|
+
// here.
|
|
475
|
+
var chromeKeys = Object.keys(chrome);
|
|
476
|
+
for (var ci = 0; ci < chromeKeys.length; ci += 1) {
|
|
477
|
+
var ck = chromeKeys[ci];
|
|
478
|
+
if (ck === "nav_cart_aria" || ck === "locale_switcher_label" || ck === "locale_switcher_submit") continue;
|
|
479
|
+
vars[ck] = chrome[ck];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return _render(LAYOUT, vars)
|
|
483
|
+
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
418
484
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
419
485
|
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
486
|
+
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
|
|
420
487
|
.replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
421
488
|
// The body is RAW HTML (already rendered + escaped at the
|
|
422
489
|
// per-fragment level). The placeholder swap is post-render so the
|
|
@@ -3963,6 +4030,39 @@ function _clearCurrencyCookie(res) {
|
|
|
3963
4030
|
_cookieJar().clear(res, CURRENCY_COOKIE_NAME);
|
|
3964
4031
|
}
|
|
3965
4032
|
|
|
4033
|
+
// ---- locale cookie -----------------------------------------------------
|
|
4034
|
+
//
|
|
4035
|
+
// The visitor's locale choice rides an UNSEALED cookie. Unlike the
|
|
4036
|
+
// session / auth / referral cookies, the locale carries no authority —
|
|
4037
|
+
// it only selects which operator-configured, server-resolved locale the
|
|
4038
|
+
// chrome renders in (every candidate is validated against the active
|
|
4039
|
+
// policy's supported list; an unknown / garbage value resolves to the
|
|
4040
|
+
// default locale). It is deliberately not vault-sealed so the value
|
|
4041
|
+
// resolves IDENTICALLY on both render substrates: the edge Worker has no
|
|
4042
|
+
// vault and couldn't unseal a sealed cookie, which would split the
|
|
4043
|
+
// edge/container resolution. The framework cookie primitive still
|
|
4044
|
+
// applies the RFC 6265 grammar, the `Secure` / `SameSite=Lax` / `Path=/`
|
|
4045
|
+
// invariants, and (in production) the cookie-prefix policy. A read still
|
|
4046
|
+
// shape-validates the value (BCP-47) before it reaches the resolver, so
|
|
4047
|
+
// a tampered cookie is dropped, never trusted.
|
|
4048
|
+
var LOCALE_COOKIE_NAME = "shop_locale";
|
|
4049
|
+
// BCP-47 envelope mirror of localeRouter.LOCALE_RE — the cookie value is
|
|
4050
|
+
// shape-checked before it's handed to the resolver so a hostile cookie
|
|
4051
|
+
// can't smuggle a non-tag string through.
|
|
4052
|
+
var LOCALE_COOKIE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
4053
|
+
var LOCALE_COOKIE_MAX = 35;
|
|
4054
|
+
|
|
4055
|
+
function _readLocaleCookie(req) {
|
|
4056
|
+
var v = _cookieJar().read(req, LOCALE_COOKIE_NAME);
|
|
4057
|
+
if (!v || typeof v !== "string" || v.length > LOCALE_COOKIE_MAX) return null;
|
|
4058
|
+
return LOCALE_COOKIE_RE.test(v) ? v : null;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
function _setLocaleCookie(res, locale) {
|
|
4062
|
+
var T = b.constants.TIME;
|
|
4063
|
+
_cookieJar().write(res, LOCALE_COOKIE_NAME, locale, { expires: new Date(Date.now() + T.days(365)) });
|
|
4064
|
+
}
|
|
4065
|
+
|
|
3966
4066
|
// ---- account-page renderers --------------------------------------------
|
|
3967
4067
|
|
|
3968
4068
|
var ACCOUNT_LOGIN_PAGE =
|
|
@@ -4300,6 +4400,234 @@ function mount(router, deps) {
|
|
|
4300
4400
|
return bundle;
|
|
4301
4401
|
}
|
|
4302
4402
|
|
|
4403
|
+
// ---- locale / i18n chrome ---------------------------------------------
|
|
4404
|
+
//
|
|
4405
|
+
// The storefront renders its UI chrome (nav, footer, search controls,
|
|
4406
|
+
// newsletter band, locale switcher) localised per request. The locale
|
|
4407
|
+
// is resolved the SAME way the edge Worker resolves it — cookie-first,
|
|
4408
|
+
// then `?lang=`, then Accept-Language, then the policy default — so a
|
|
4409
|
+
// visitor sees identical chrome whether the request is served at the
|
|
4410
|
+
// edge or by this container. The strings come from `b.i18n` over the
|
|
4411
|
+
// chrome catalog in `lib/translations.js`, layered with the operator's
|
|
4412
|
+
// `ui`/`chrome` translation rows; a missing key falls back to the
|
|
4413
|
+
// English baseline (never a raw key).
|
|
4414
|
+
//
|
|
4415
|
+
// `deps.chromeI18n` is the `b.i18n` instance (built once at boot via
|
|
4416
|
+
// translations.createChromeI18n with the operator's overrides). When
|
|
4417
|
+
// absent — a deploy with no locale policy seeded — the storefront
|
|
4418
|
+
// renders the English baseline and shows no switcher. `deps.localeRouter`
|
|
4419
|
+
// is the optional resolver; `deps.localeOptions` carries the default
|
|
4420
|
+
// locale + the active-locale list the switcher renders.
|
|
4421
|
+
var chromeI18n = deps.chromeI18n || null;
|
|
4422
|
+
var localeRouter = deps.localeRouter || null;
|
|
4423
|
+
var localeOptions = deps.localeOptions || {};
|
|
4424
|
+
var defaultLocale = localeOptions.defaultLocale || translationsModule.BASELINE_LOCALE;
|
|
4425
|
+
// Active locales the switcher offers: [{ tag, label }]. Operators
|
|
4426
|
+
// declare the label (the autonym — "Deutsch", "Français") in
|
|
4427
|
+
// localeOptions; absent that, the tag itself is the label.
|
|
4428
|
+
var activeLocales = Array.isArray(localeOptions.locales) ? localeOptions.locales : [];
|
|
4429
|
+
// Supported tags for the cookie/`?lang=` direct-match fast path.
|
|
4430
|
+
var supportedTags = activeLocales.map(function (l) { return l.tag; });
|
|
4431
|
+
// i18n RTL helper — `b.i18n.dir({ locale })` returns "rtl"/"ltr".
|
|
4432
|
+
function _dirFor(locale) {
|
|
4433
|
+
if (!chromeI18n) return "ltr";
|
|
4434
|
+
try { return chromeI18n.dir({ locale: locale }); } catch (_e) { return "ltr"; }
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
// Per-locale resolved chrome string set, memoised — the strings don't
|
|
4438
|
+
// change per request (only the cart count, interpolated at render
|
|
4439
|
+
// time), so resolve each locale once.
|
|
4440
|
+
var _chromeByLocale = Object.create(null);
|
|
4441
|
+
function _chromeFor(locale) {
|
|
4442
|
+
if (!chromeI18n) return translationsModule.chromeDefaults();
|
|
4443
|
+
if (_chromeByLocale[locale]) return _chromeByLocale[locale];
|
|
4444
|
+
var resolved = translationsModule.resolveChrome(chromeI18n, locale);
|
|
4445
|
+
_chromeByLocale[locale] = resolved;
|
|
4446
|
+
return resolved;
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
// Match a candidate tag against the supported set — the SAME rule the
|
|
4450
|
+
// locale-router uses: a case-insensitive direct hit, then a primary-
|
|
4451
|
+
// subtag match (`en-GB` candidate -> supported `en`). Returns the
|
|
4452
|
+
// matched supported tag (catalog casing) or null. Kept in-process so
|
|
4453
|
+
// resolution is synchronous — the middleware must seed the async-local
|
|
4454
|
+
// store within one synchronous tick (an `await` inside the middleware
|
|
4455
|
+
// would set the store in the awaited frame, not the request's).
|
|
4456
|
+
var _supportedLowerToTag = Object.create(null);
|
|
4457
|
+
for (var _si = 0; _si < supportedTags.length; _si += 1) {
|
|
4458
|
+
_supportedLowerToTag[String(supportedTags[_si]).toLowerCase()] = supportedTags[_si];
|
|
4459
|
+
}
|
|
4460
|
+
function _matchSupported(candidate) {
|
|
4461
|
+
if (!candidate) return null;
|
|
4462
|
+
var lowered = String(candidate).toLowerCase();
|
|
4463
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, lowered)) {
|
|
4464
|
+
return _supportedLowerToTag[lowered];
|
|
4465
|
+
}
|
|
4466
|
+
var primary = lowered.split("-")[0];
|
|
4467
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, primary)) {
|
|
4468
|
+
return _supportedLowerToTag[primary];
|
|
4469
|
+
}
|
|
4470
|
+
return null;
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
// Parse an Accept-Language header into a q-sorted list of tags. A
|
|
4474
|
+
// garbage header yields an empty list (the resolver then falls to the
|
|
4475
|
+
// default). Mirrors the locale-router's parser shape.
|
|
4476
|
+
function _parseAcceptLanguage(raw) {
|
|
4477
|
+
if (typeof raw !== "string" || !raw.length || raw.length > 4096) return [];
|
|
4478
|
+
var out = [];
|
|
4479
|
+
var parts = raw.split(",");
|
|
4480
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
4481
|
+
var part = parts[i].trim();
|
|
4482
|
+
if (!part) continue;
|
|
4483
|
+
var semi = part.indexOf(";");
|
|
4484
|
+
var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
|
|
4485
|
+
var q = 1.0;
|
|
4486
|
+
if (semi !== -1) {
|
|
4487
|
+
var m = /q=([0-9.]+)/.exec(part.slice(semi + 1));
|
|
4488
|
+
if (m) { var n = parseFloat(m[1]); if (isFinite(n) && n >= 0 && n <= 1) q = n; }
|
|
4489
|
+
}
|
|
4490
|
+
if (tag === "*" || !LOCALE_COOKIE_RE.test(tag) || tag.length > LOCALE_COOKIE_MAX) continue;
|
|
4491
|
+
out.push({ tag: tag, q: q, order: i });
|
|
4492
|
+
}
|
|
4493
|
+
out.sort(function (a, c) { return a.q !== c.q ? c.q - a.q : a.order - c.order; });
|
|
4494
|
+
return out;
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4497
|
+
// Resolve the request's locale synchronously, in the SAME precedence
|
|
4498
|
+
// the edge Worker / locale-router use: an explicit `?lang=` choice,
|
|
4499
|
+
// then the persisted cookie, then the browser's Accept-Language list,
|
|
4500
|
+
// then the policy default. Only a tag that resolves into the active
|
|
4501
|
+
// supported set is honoured; anything else (unknown code, garbage
|
|
4502
|
+
// cookie) falls through to the default. Never throws.
|
|
4503
|
+
function _resolveRequestLocale(req) {
|
|
4504
|
+
var queryLocale = null;
|
|
4505
|
+
if (req.query && typeof req.query.lang === "string" &&
|
|
4506
|
+
LOCALE_COOKIE_RE.test(req.query.lang) && req.query.lang.length <= LOCALE_COOKIE_MAX) {
|
|
4507
|
+
queryLocale = req.query.lang;
|
|
4508
|
+
}
|
|
4509
|
+
var qMatch = _matchSupported(queryLocale);
|
|
4510
|
+
if (qMatch) return qMatch;
|
|
4511
|
+
|
|
4512
|
+
var cMatch = _matchSupported(_readLocaleCookie(req));
|
|
4513
|
+
if (cMatch) return cMatch;
|
|
4514
|
+
|
|
4515
|
+
var alHeader = req.headers && (req.headers["accept-language"] || req.headers["Accept-Language"]);
|
|
4516
|
+
var alList = _parseAcceptLanguage(alHeader);
|
|
4517
|
+
for (var i = 0; i < alList.length; i += 1) {
|
|
4518
|
+
var alMatch = _matchSupported(alList[i].tag);
|
|
4519
|
+
if (alMatch) return alMatch;
|
|
4520
|
+
}
|
|
4521
|
+
return defaultLocale;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
// Best-effort: record the resolution in the locale-router's audit log
|
|
4525
|
+
// (locale_resolutions_log). Fire-and-forget — never awaited, never
|
|
4526
|
+
// blocks the request, never throws into the hot path. Wired only when
|
|
4527
|
+
// the locale-router is present.
|
|
4528
|
+
function _logResolution(req, locale) {
|
|
4529
|
+
if (!localeRouter || typeof localeRouter.resolveLocale !== "function") return;
|
|
4530
|
+
try {
|
|
4531
|
+
var hostHeader = (req.headers && (req.headers.host || req.headers.Host)) || "localhost";
|
|
4532
|
+
var p = localeRouter.resolveLocale({
|
|
4533
|
+
request: {
|
|
4534
|
+
host: String(hostHeader),
|
|
4535
|
+
path: req.pathname || (String(req.url || "/").split("?")[0]) || "/",
|
|
4536
|
+
cookie_locale: locale,
|
|
4537
|
+
accept_language: undefined,
|
|
4538
|
+
},
|
|
4539
|
+
});
|
|
4540
|
+
if (p && typeof p.then === "function") p.then(function () {}, function () {});
|
|
4541
|
+
} catch (_e) { /* drop-silent — the audit log is non-critical */ }
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
// Build the server-rendered locale switcher form. A GET form (works
|
|
4545
|
+
// with JS off) that submits the chosen `lang` to /locale, which sets
|
|
4546
|
+
// the cookie and 303-redirects back to the page the visitor was on
|
|
4547
|
+
// (validated same-origin). Rendered only when there's more than one
|
|
4548
|
+
// active locale to choose between.
|
|
4549
|
+
function _switcherHtml(req, activeLocale, chrome) {
|
|
4550
|
+
if (activeLocales.length < 2) return "";
|
|
4551
|
+
var esc = b.template.escapeHtml;
|
|
4552
|
+
// `to` carries the current path+query so the redirect lands the
|
|
4553
|
+
// visitor back where they were. Validated server-side in /locale.
|
|
4554
|
+
var here = req.pathname || (String(req.url || "/").split("?")[0]) || "/";
|
|
4555
|
+
var options = activeLocales.map(function (l) {
|
|
4556
|
+
var sel = l.tag === activeLocale ? " selected" : "";
|
|
4557
|
+
return "<option value=\"" + esc(l.tag) + "\"" + sel + ">" + esc(l.label || l.tag) + "</option>";
|
|
4558
|
+
}).join("");
|
|
4559
|
+
return "" +
|
|
4560
|
+
" <form class=\"locale-switcher\" method=\"get\" action=\"/locale\">\n" +
|
|
4561
|
+
" <label class=\"locale-switcher__label\" for=\"locale-switcher-select\">" + esc(chrome.locale_switcher_label) + "</label>\n" +
|
|
4562
|
+
" <input type=\"hidden\" name=\"to\" value=\"" + esc(here) + "\">\n" +
|
|
4563
|
+
" <select id=\"locale-switcher-select\" class=\"locale-switcher__select\" name=\"lang\">" + options + "</select>\n" +
|
|
4564
|
+
" <button class=\"locale-switcher__submit\" type=\"submit\">" + esc(chrome.locale_switcher_submit) + "</button>\n" +
|
|
4565
|
+
" </form>\n";
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
// The per-request locale context the layout reads via the async-local
|
|
4569
|
+
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
4570
|
+
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html }.
|
|
4571
|
+
// Never throws — any failure falls back to the default locale.
|
|
4572
|
+
function _localeCtx(req) {
|
|
4573
|
+
var locale = defaultLocale;
|
|
4574
|
+
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
4575
|
+
var chrome = _chromeFor(locale);
|
|
4576
|
+
return {
|
|
4577
|
+
locale: locale,
|
|
4578
|
+
lang: locale,
|
|
4579
|
+
dir: _dirFor(locale),
|
|
4580
|
+
chrome: chrome,
|
|
4581
|
+
switcher_html: _switcherHtml(req, locale, chrome),
|
|
4582
|
+
};
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
// Seed the per-request locale context into the async-local store so
|
|
4586
|
+
// every `_wrap` on this request reads the resolved chrome. The
|
|
4587
|
+
// middleware is SYNCHRONOUS: `AsyncLocalStorage.enterWith` only
|
|
4588
|
+
// propagates to the request's downstream handlers when it runs in the
|
|
4589
|
+
// same synchronous tick the router awaits — an `await` inside the
|
|
4590
|
+
// middleware would scope the store to the awaited frame, not the
|
|
4591
|
+
// request. Resolution is best-effort (falls back to the English
|
|
4592
|
+
// baseline). The audit-log write is fired off without awaiting so it
|
|
4593
|
+
// never blocks the render. Only mounted when the router exposes `.use`.
|
|
4594
|
+
if (typeof router.use === "function") {
|
|
4595
|
+
router.use(function localeMiddleware(req, _res, next) {
|
|
4596
|
+
try {
|
|
4597
|
+
var ctx = _localeCtx(req);
|
|
4598
|
+
_localeAls.enterWith(ctx);
|
|
4599
|
+
_logResolution(req, ctx.locale);
|
|
4600
|
+
} catch (_e) { /* drop-silent — baseline applies */ }
|
|
4601
|
+
next();
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
// Persist a locale choice. A GET form (works with JS off) from the
|
|
4606
|
+
// footer switcher submits `lang` (the chosen tag) + `to` (the path to
|
|
4607
|
+
// return to). We validate the tag against the active locale set, set
|
|
4608
|
+
// the unsealed `shop_locale` cookie, and 303-redirect to the `to`
|
|
4609
|
+
// path — refusing anything that isn't a same-origin absolute path so
|
|
4610
|
+
// the redirect can't be turned into an open-redirect. Unknown / bad
|
|
4611
|
+
// `lang` still redirects (to "/" or the validated `to`) without
|
|
4612
|
+
// setting a cookie, so a hostile link can't 500 the route.
|
|
4613
|
+
router.get("/locale", function (req, res) {
|
|
4614
|
+
var q = req.query || {};
|
|
4615
|
+
var to = (typeof q.to === "string") ? q.to : "/";
|
|
4616
|
+
// Same-origin path only: must start with a single "/" (not "//" — a
|
|
4617
|
+
// protocol-relative URL — and not a scheme). Anything else falls
|
|
4618
|
+
// back to the home path.
|
|
4619
|
+
if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
|
|
4620
|
+
to = "/";
|
|
4621
|
+
}
|
|
4622
|
+
var lang = (typeof q.lang === "string") ? q.lang : "";
|
|
4623
|
+
if (lang && LOCALE_COOKIE_RE.test(lang) && lang.length <= LOCALE_COOKIE_MAX && supportedTags.indexOf(lang) !== -1) {
|
|
4624
|
+
_setLocaleCookie(res, lang);
|
|
4625
|
+
}
|
|
4626
|
+
res.status(303);
|
|
4627
|
+
res.setHeader && res.setHeader("location", to);
|
|
4628
|
+
return res.end ? res.end() : res.send("");
|
|
4629
|
+
});
|
|
4630
|
+
|
|
4303
4631
|
// Absolute shareable referral link for a code. Prefers the operator's
|
|
4304
4632
|
// configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
|
|
4305
4633
|
// stable across the edge/container split; falls back to the request's
|
package/lib/translations.js
CHANGED
|
@@ -537,10 +537,213 @@ function _hydrate(row) {
|
|
|
537
537
|
};
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
// ---- storefront UI chrome catalog --------------------------------------
|
|
541
|
+
//
|
|
542
|
+
// The chrome strings the storefront renders on EVERY page — nav links,
|
|
543
|
+
// search controls, footer columns + links, the newsletter band, the
|
|
544
|
+
// locale switcher. These are the default-locale (`en`) baseline; an
|
|
545
|
+
// operator localises any key by authoring a `translations` row under
|
|
546
|
+
// resource_kind `ui`, resource_id `chrome`, field = the dotted key with
|
|
547
|
+
// dots rewritten to underscores (the table's `field` slug grammar
|
|
548
|
+
// forbids dots), locale = the target tag. `resolveChrome` reads those
|
|
549
|
+
// overrides at boot per active locale and layers them over this baseline,
|
|
550
|
+
// so an untranslated key always renders the English string — never a
|
|
551
|
+
// raw key. The exact English values here are the literal strings the
|
|
552
|
+
// layout shipped inline before i18n landed, so an `en` storefront is
|
|
553
|
+
// byte-identical to the pre-i18n markup.
|
|
554
|
+
//
|
|
555
|
+
// Both render paths (the container `lib/storefront.js` and the edge
|
|
556
|
+
// `worker/render/*`) consume this same catalog through `b.i18n`, so a
|
|
557
|
+
// given locale resolves to one identical string set on both sides.
|
|
558
|
+
var CHROME_KEY_PREFIX = "ui.chrome.";
|
|
559
|
+
var CHROME_RESOURCE_KIND = "ui";
|
|
560
|
+
var CHROME_RESOURCE_ID = "chrome";
|
|
561
|
+
|
|
562
|
+
var CHROME_DEFAULTS = Object.freeze({
|
|
563
|
+
skip_to_content: "Skip to content",
|
|
564
|
+
|
|
565
|
+
util_pill: "Open source · Apache 2.0",
|
|
566
|
+
util_msg: "Server-rendered HTML · post-quantum crypto on by default · zero npm runtime deps",
|
|
567
|
+
util_star: "Star on GitHub →",
|
|
568
|
+
|
|
569
|
+
search_label: "Search products",
|
|
570
|
+
search_placeholder: "Search the catalog",
|
|
571
|
+
search_submit: "Search",
|
|
572
|
+
|
|
573
|
+
nav_shop: "Shop",
|
|
574
|
+
nav_framework: "Framework",
|
|
575
|
+
nav_account: "Account",
|
|
576
|
+
// The cart aria-label carries the live item count; `{count}` is
|
|
577
|
+
// interpolated by b.i18n at render time.
|
|
578
|
+
nav_cart_aria: "Cart, {count} items",
|
|
579
|
+
|
|
580
|
+
newsletter_eyebrow: "Stay in the loop",
|
|
581
|
+
newsletter_title: "Get release notes the day they ship.",
|
|
582
|
+
newsletter_lede: "No marketing emails. A single short note when there's a new framework release, a security advisory, or a primitive worth knowing about.",
|
|
583
|
+
newsletter_email: "Email address",
|
|
584
|
+
newsletter_submit: "Subscribe",
|
|
585
|
+
|
|
586
|
+
footer_tagline: "An open-source shop framework — server-rendered HTML, zero npm runtime dependencies, security defaults on.",
|
|
587
|
+
|
|
588
|
+
footer_shop_heading: "Shop",
|
|
589
|
+
footer_shop_all: "All products",
|
|
590
|
+
footer_shop_collections: "Collections",
|
|
591
|
+
footer_shop_categories: "Categories",
|
|
592
|
+
footer_shop_new: "New arrivals",
|
|
593
|
+
footer_shop_sale: "On sale",
|
|
594
|
+
footer_shop_compare: "Compare",
|
|
595
|
+
footer_shop_cart: "Cart",
|
|
596
|
+
|
|
597
|
+
footer_framework_heading: "Framework",
|
|
598
|
+
footer_framework_source: "Source on GitHub",
|
|
599
|
+
footer_framework_core: "blamejs core",
|
|
600
|
+
footer_framework_security: "Security policy",
|
|
601
|
+
footer_framework_changelog: "Changelog",
|
|
602
|
+
|
|
603
|
+
footer_operators_heading: "Operators",
|
|
604
|
+
footer_operators_account: "Account",
|
|
605
|
+
footer_operators_orders: "Orders",
|
|
606
|
+
footer_operators_admin: "Admin",
|
|
607
|
+
footer_operators_contact: "Contact",
|
|
608
|
+
|
|
609
|
+
footer_copy_suffix: "built on blamejs · Apache 2.0 licensed.",
|
|
610
|
+
footer_legal_security: "Security",
|
|
611
|
+
footer_legal_privacy: "Privacy",
|
|
612
|
+
footer_legal_terms: "Terms",
|
|
613
|
+
footer_legal_cookies: "Manage cookies",
|
|
614
|
+
|
|
615
|
+
locale_switcher_label: "Language",
|
|
616
|
+
locale_switcher_submit: "Go",
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Default English chrome strings exposed verbatim for the render paths
|
|
620
|
+
// to fall back on when no i18n instance is configured (the storefront
|
|
621
|
+
// stays browsable on a deploy that hasn't seeded a locale policy).
|
|
622
|
+
function chromeDefaults() {
|
|
623
|
+
return Object.assign({}, CHROME_DEFAULTS);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Build a `b.i18n` instance carrying the chrome catalog for every active
|
|
627
|
+
// locale. The baseline `en` tree is the shipped defaults; each other
|
|
628
|
+
// locale's tree starts empty and is layered with the operator's `ui`
|
|
629
|
+
// translation rows (read once here). b.i18n's own lookup chain then
|
|
630
|
+
// falls a missing key through to `defaultLocale` (the shipped English),
|
|
631
|
+
// so a partially-translated locale never surfaces a raw key.
|
|
632
|
+
//
|
|
633
|
+
// `opts.overrides` is the pre-read DB override map
|
|
634
|
+
// ({ "<locale>": { "<field>": "<value>", ... } }) — passed in by the
|
|
635
|
+
// caller (the storefront mount reads it via `readChromeOverrides`)
|
|
636
|
+
// rather than read here, so this stays a synchronous, query-free build
|
|
637
|
+
// that the edge Worker can run with the same inputs as the container.
|
|
638
|
+
function createChromeI18n(opts) {
|
|
639
|
+
opts = opts || {};
|
|
640
|
+
var defaultLocale = opts.defaultLocale || BASELINE_LOCALE;
|
|
641
|
+
var locales = Array.isArray(opts.locales) && opts.locales.length
|
|
642
|
+
? opts.locales.slice()
|
|
643
|
+
: [defaultLocale];
|
|
644
|
+
if (locales.indexOf(defaultLocale) === -1) locales.unshift(defaultLocale);
|
|
645
|
+
var overrides = opts.overrides || {};
|
|
646
|
+
|
|
647
|
+
// Each locale's inline tree under the single `ui.chrome.*` namespace.
|
|
648
|
+
// The default locale gets the full English baseline; other locales get
|
|
649
|
+
// only their authored overrides (the rest falls through b.i18n's chain
|
|
650
|
+
// to the default locale).
|
|
651
|
+
var translations = {};
|
|
652
|
+
for (var li = 0; li < locales.length; li += 1) {
|
|
653
|
+
var loc = locales[li];
|
|
654
|
+
var chrome = {};
|
|
655
|
+
if (loc === defaultLocale) {
|
|
656
|
+
var defKeys = Object.keys(CHROME_DEFAULTS);
|
|
657
|
+
for (var d = 0; d < defKeys.length; d += 1) chrome[defKeys[d]] = CHROME_DEFAULTS[defKeys[d]];
|
|
658
|
+
}
|
|
659
|
+
var ovr = overrides[loc];
|
|
660
|
+
if (ovr) {
|
|
661
|
+
var ovrKeys = Object.keys(ovr);
|
|
662
|
+
for (var o = 0; o < ovrKeys.length; o += 1) {
|
|
663
|
+
// Only keys we know about land in the tree — an operator typo
|
|
664
|
+
// (a field that isn't a chrome key) is ignored rather than
|
|
665
|
+
// shadowing a real string with garbage.
|
|
666
|
+
if (Object.prototype.hasOwnProperty.call(CHROME_DEFAULTS, ovrKeys[o])) {
|
|
667
|
+
chrome[ovrKeys[o]] = ovr[ovrKeys[o]];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
translations[loc] = { ui: { chrome: chrome } };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return b.i18n.create({
|
|
675
|
+
defaultLocale: defaultLocale,
|
|
676
|
+
locales: locales,
|
|
677
|
+
fallbackLocale: defaultLocale,
|
|
678
|
+
translations: translations,
|
|
679
|
+
// A missing chrome key returns the English baseline via the chain;
|
|
680
|
+
// should a key be absent from every locale (only possible if the
|
|
681
|
+
// baseline catalog itself is edited), return the dotted key rather
|
|
682
|
+
// than throwing inside a render. The render path never shows it
|
|
683
|
+
// because every shipped key has an `en` baseline.
|
|
684
|
+
missingKey: "return-key",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Resolve the full chrome string set for one locale into a flat object
|
|
689
|
+
// keyed by the chrome field names (`nav_shop`, `footer_shop_all`, …),
|
|
690
|
+
// each value already locale-resolved through the i18n chain. The render
|
|
691
|
+
// paths feed this straight into the layout placeholders. The
|
|
692
|
+
// `nav_cart_aria` string keeps its literal `{count}` placeholder — the
|
|
693
|
+
// render path interpolates the live cart count uniformly across both
|
|
694
|
+
// substrates, so this stays request-independent and cacheable.
|
|
695
|
+
function resolveChrome(i18n, locale) {
|
|
696
|
+
var out = {};
|
|
697
|
+
var keys = Object.keys(CHROME_DEFAULTS);
|
|
698
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
699
|
+
var key = keys[i];
|
|
700
|
+
// No interpolation vars — `{count}` (and any future placeholder)
|
|
701
|
+
// survives as a literal for the render path to fill.
|
|
702
|
+
out[key] = i18n.t(CHROME_KEY_PREFIX + key, {}, { locale: locale });
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Read the operator's `ui`/`chrome` override rows for the given locales
|
|
708
|
+
// into the `{ "<locale>": { "<field>": "<value>" } }` shape
|
|
709
|
+
// `createChromeI18n` consumes. Missing-table-resilient: a deploy whose
|
|
710
|
+
// `translations` table hasn't been migrated (or a transient read error)
|
|
711
|
+
// yields an empty override map, so the storefront falls back to the
|
|
712
|
+
// English baseline rather than 500-ing. The caller passes a `query`
|
|
713
|
+
// function (the externalDb handle).
|
|
714
|
+
async function readChromeOverrides(query, locales) {
|
|
715
|
+
if (typeof query !== "function" || !Array.isArray(locales) || !locales.length) return {};
|
|
716
|
+
var out = {};
|
|
717
|
+
try {
|
|
718
|
+
var placeholders = locales.map(function (_l, i) { return "?" + (i + 3); }).join(", ");
|
|
719
|
+
var rows = (await query(
|
|
720
|
+
"SELECT locale, field, value FROM translations " +
|
|
721
|
+
"WHERE resource_kind = ?1 AND resource_id = ?2 AND locale IN (" + placeholders + ")",
|
|
722
|
+
[CHROME_RESOURCE_KIND, CHROME_RESOURCE_ID].concat(locales)
|
|
723
|
+
)).rows;
|
|
724
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
725
|
+
var r = rows[i];
|
|
726
|
+
if (!out[r.locale]) out[r.locale] = {};
|
|
727
|
+
out[r.locale][r.field] = r.value;
|
|
728
|
+
}
|
|
729
|
+
} catch (_e) {
|
|
730
|
+
// Missing table / read failure — degrade to the English baseline.
|
|
731
|
+
return {};
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
540
736
|
module.exports = {
|
|
541
737
|
create: create,
|
|
542
738
|
MAX_VALUE_LEN: MAX_VALUE_LEN,
|
|
543
739
|
MAX_BULK_ROWS: MAX_BULK_ROWS,
|
|
544
740
|
BASELINE_LOCALE: BASELINE_LOCALE,
|
|
545
741
|
DEFAULT_SAMPLE_SIZE: DEFAULT_SAMPLE_SIZE,
|
|
742
|
+
CHROME_DEFAULTS: CHROME_DEFAULTS,
|
|
743
|
+
CHROME_RESOURCE_KIND: CHROME_RESOURCE_KIND,
|
|
744
|
+
CHROME_RESOURCE_ID: CHROME_RESOURCE_ID,
|
|
745
|
+
chromeDefaults: chromeDefaults,
|
|
746
|
+
createChromeI18n: createChromeI18n,
|
|
747
|
+
resolveChrome: resolveChrome,
|
|
748
|
+
readChromeOverrides: readChromeOverrides,
|
|
546
749
|
};
|
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.6",
|
|
7
|
+
"tag": "v0.13.6",
|
|
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.6 (2026-05-26) — **`b.ai.frontierModelProtocol` — California SB 53 frontier-AI obligations.** b.ai.frontierModelProtocol assesses a developer's obligations under California's Transparency in Frontier Artificial Intelligence Act — SB 53, Cal. Bus. & Prof. Code §22757.10, effective 2026-01-01 — from a model's training compute and the developer's revenue. It reports whether the model crosses the frontier threshold (more than 10^26 training FLOPs), whether the developer is a large frontier developer (prior-year revenue, with affiliates, above $500M), and the resulting obligations: every frontier developer must report critical safety incidents and publish a transparency report, and a large frontier developer must additionally publish an annual safety framework and disclose its catastrophic-risk assessment. Passing a candidate safety framework reports which required elements (risk identification, mitigation, governance, cybersecurity, standards alignment) are missing. b.ai.frontierModelProtocol.incidentReport validates a critical-incident type against the Act's four categories and computes the notification deadline to the California Office of Emergency Services — 15 days from discovery, or 24 hours when there is an imminent risk of death or serious physical injury. The ca-tfaia compliance posture was already in the catalog. **Added:** *`b.ai.frontierModelProtocol` — SB 53 threshold classification, obligations, and incident reporting* — `b.ai.frontierModelProtocol({ trainingFlops, annualRevenueUsd, framework? })` returns `isFrontierModel`, `isLargeFrontierDeveloper`, the `obligations` list, and (when a framework is supplied) its `frameworkGaps`. `b.ai.frontierModelProtocol.incidentReport({ type, discoveredAt, imminentRiskToLife? })` builds a critical-safety-incident report with the California OES recipient and a `dueAt` / `deadlineHours` computed from the 15-day (or 24-hour imminent-risk) statutory window; `type` must be one of the four categories in `INCIDENT_TYPES`. Throws `FrontierProtocolError` on malformed input.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
|
@@ -190,6 +190,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
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
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
|
|
193
|
+
- **Frontier AI protocol** — California SB 53 (Transparency in Frontier AI Act) obligations (`b.ai.frontierModelProtocol`): classify the frontier-model (>10²⁶ training FLOPs) and large-frontier-developer (>$500M revenue) thresholds, enumerate the resulting obligations, check a safety framework for required elements, and build a critical-safety-incident report with the 15-day / 24-hour California OES notification deadline (`.incidentReport`)
|
|
193
194
|
### Compliance regimes
|
|
194
195
|
|
|
195
196
|
- **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.6",
|
|
4
|
+
"createdAt": "2026-05-27T02:34:13.690Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -1577,6 +1577,10 @@
|
|
|
1577
1577
|
}
|
|
1578
1578
|
}
|
|
1579
1579
|
},
|
|
1580
|
+
"frontierModelProtocol": {
|
|
1581
|
+
"type": "function",
|
|
1582
|
+
"arity": 1
|
|
1583
|
+
},
|
|
1580
1584
|
"input": {
|
|
1581
1585
|
"type": "object",
|
|
1582
1586
|
"members": {
|
|
@@ -478,6 +478,7 @@ module.exports = {
|
|
|
478
478
|
capability: require("./lib/ai-capability"),
|
|
479
479
|
dp: require("./lib/ai-dp"),
|
|
480
480
|
aedtBiasAudit: require("./lib/ai-aedt-bias-audit"),
|
|
481
|
+
frontierModelProtocol: require("./lib/ai-frontier-protocol"),
|
|
481
482
|
},
|
|
482
483
|
promisePool: require("./lib/promise-pool"),
|
|
483
484
|
sdNotify: require("./lib/sd-notify"),
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.frontierModelProtocol
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title Frontier AI Protocol
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Assess a developer's obligations under California's Transparency in
|
|
9
|
+
* Frontier Artificial Intelligence Act — SB 53, Cal. Bus. & Prof. Code
|
|
10
|
+
* §22757.10 et seq., signed 2025-09-29 and effective 2026-01-01 — and build
|
|
11
|
+
* the critical-safety-incident report the law requires. SB 53 attaches
|
|
12
|
+
* obligations by two thresholds: a <em>frontier model</em> is one trained
|
|
13
|
+
* with more than 10<sup>26</sup> floating-point operations (including
|
|
14
|
+
* cumulative fine-tuning), and a <em>large frontier developer</em> is a
|
|
15
|
+
* frontier developer whose annual revenue, with affiliates, exceeded
|
|
16
|
+
* $500,000,000 in the prior calendar year. Frontier developers must report
|
|
17
|
+
* critical safety incidents; large frontier developers must additionally
|
|
18
|
+
* publish an annual frontier-AI safety framework.
|
|
19
|
+
*
|
|
20
|
+
* <code>frontierModelProtocol(opts)</code> takes the model's training compute
|
|
21
|
+
* and the developer's revenue and returns which thresholds are crossed and
|
|
22
|
+
* the resulting obligations, optionally checking that a supplied safety
|
|
23
|
+
* framework carries the elements the Act expects (risk identification,
|
|
24
|
+
* mitigation, governance, cybersecurity, and alignment with a recognized
|
|
25
|
+
* standard such as the NIST AI RMF or ISO/IEC 42001).
|
|
26
|
+
* <code>frontierModelProtocol.incidentReport(opts)</code> validates a
|
|
27
|
+
* critical-incident type against the Act's four definitions and computes the
|
|
28
|
+
* notification deadline: a routine report goes to the California Office of
|
|
29
|
+
* Emergency Services within 15 days of discovery; an imminent risk of death
|
|
30
|
+
* or serious physical injury is reported to an applicable authority within 24
|
|
31
|
+
* hours.
|
|
32
|
+
*
|
|
33
|
+
* @card
|
|
34
|
+
* California SB 53 frontier-AI protocol (`b.ai.frontierModelProtocol`) —
|
|
35
|
+
* classify frontier-model / large-developer thresholds (10²⁶ FLOPs, $500M
|
|
36
|
+
* revenue), enumerate obligations, and build the critical-safety-incident
|
|
37
|
+
* report with the 15-day / 24-hour OES notification deadline.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
var C = require("./constants");
|
|
41
|
+
var { defineClass } = require("./framework-error");
|
|
42
|
+
|
|
43
|
+
var FrontierProtocolError = defineClass("FrontierProtocolError", { alwaysPermanent: true });
|
|
44
|
+
|
|
45
|
+
// SB 53 thresholds.
|
|
46
|
+
var FRONTIER_FLOP_THRESHOLD = 1e26; // > 10^26 training FLOPs → frontier model
|
|
47
|
+
var LARGE_DEVELOPER_REVENUE_USD = 5e8; // > $500,000,000 prior-year revenue → large developer
|
|
48
|
+
var INCIDENT_DEADLINE_MS = C.TIME.days(15); // report to CA OES within 15 days of discovery
|
|
49
|
+
var IMMINENT_DEADLINE_MS = C.TIME.hours(24); // within 24 hours if imminent risk to life
|
|
50
|
+
|
|
51
|
+
// The four critical safety incident categories (Cal. Bus. & Prof. Code §22757.10).
|
|
52
|
+
var CRITICAL_INCIDENT_TYPES = {
|
|
53
|
+
"weights-exfiltration-harm": "Unauthorized access, modification, or exfiltration of frontier-model weights resulting in death or bodily injury",
|
|
54
|
+
"catastrophic-risk-materialization": "Harm resulting from the materialization of a catastrophic risk (including loss of life or property from a dangerous capability)",
|
|
55
|
+
"loss-of-control-harm": "Loss of control of a frontier model causing death or bodily injury",
|
|
56
|
+
"deceptive-control-subversion": "A frontier model using deceptive techniques to subvert developer controls, demonstrating materially increased catastrophic risk",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Elements a large frontier developer's published safety framework must address.
|
|
60
|
+
var REQUIRED_FRAMEWORK_ELEMENTS = ["riskIdentification", "riskMitigation", "governance", "cybersecurity", "standardsAlignment"];
|
|
61
|
+
|
|
62
|
+
function _posNum(v, label) {
|
|
63
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0) throw new FrontierProtocolError("frontier/bad-value", "frontierModelProtocol: " + label + " must be a non-negative finite number");
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @primitive b.ai.frontierModelProtocol
|
|
69
|
+
* @signature b.ai.frontierModelProtocol(opts)
|
|
70
|
+
* @since 0.13.6
|
|
71
|
+
* @status stable
|
|
72
|
+
* @compliance ca-tfaia, soc2
|
|
73
|
+
* @related b.ai.aedtBiasAudit, b.ai.disclosure.applyAll
|
|
74
|
+
*
|
|
75
|
+
* Determine SB 53 obligations from a model's training compute and the
|
|
76
|
+
* developer's revenue. Returns <code>isFrontierModel</code> (training FLOPs
|
|
77
|
+
* above 10<sup>26</sup>), <code>isLargeFrontierDeveloper</code> (a frontier
|
|
78
|
+
* developer with revenue above $500M), and the resulting
|
|
79
|
+
* <code>obligations</code>. When <code>framework</code> is supplied, its
|
|
80
|
+
* required elements are checked and reported in <code>frameworkGaps</code>.
|
|
81
|
+
* Throws <code>FrontierProtocolError</code> on malformed input.
|
|
82
|
+
*
|
|
83
|
+
* @opts
|
|
84
|
+
* trainingFlops: number, // total training FLOPs incl. fine-tuning (required)
|
|
85
|
+
* annualRevenueUsd: number, // developer prior-year revenue with affiliates (default: 0)
|
|
86
|
+
* framework: object, // optional safety framework to check for required elements
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* var p = b.ai.frontierModelProtocol({ trainingFlops: 5e26, annualRevenueUsd: 1e9 });
|
|
90
|
+
* p.isFrontierModel; // → true
|
|
91
|
+
* p.isLargeFrontierDeveloper; // → true
|
|
92
|
+
* p.obligations; // → ["report-critical-safety-incidents", "publish-annual-safety-framework", ...]
|
|
93
|
+
*/
|
|
94
|
+
function frontierModelProtocol(opts) {
|
|
95
|
+
opts = opts || {};
|
|
96
|
+
if (typeof opts !== "object") throw new FrontierProtocolError("frontier/bad-opts", "frontierModelProtocol: opts must be an object");
|
|
97
|
+
var allowed = { trainingFlops: 1, annualRevenueUsd: 1, framework: 1 };
|
|
98
|
+
Object.keys(opts).forEach(function (k) { if (!allowed[k]) throw new FrontierProtocolError("frontier/bad-opts", "frontierModelProtocol: unknown option '" + k + "'"); });
|
|
99
|
+
|
|
100
|
+
var flops = _posNum(opts.trainingFlops, "trainingFlops");
|
|
101
|
+
var revenue = opts.annualRevenueUsd != null ? _posNum(opts.annualRevenueUsd, "annualRevenueUsd") : 0;
|
|
102
|
+
|
|
103
|
+
var isFrontierModel = flops > FRONTIER_FLOP_THRESHOLD;
|
|
104
|
+
var isLargeFrontierDeveloper = isFrontierModel && revenue > LARGE_DEVELOPER_REVENUE_USD;
|
|
105
|
+
|
|
106
|
+
var obligations = [];
|
|
107
|
+
if (isFrontierModel) {
|
|
108
|
+
obligations.push("report-critical-safety-incidents");
|
|
109
|
+
obligations.push("publish-transparency-report");
|
|
110
|
+
}
|
|
111
|
+
if (isLargeFrontierDeveloper) {
|
|
112
|
+
obligations.push("publish-annual-safety-framework");
|
|
113
|
+
obligations.push("disclose-catastrophic-risk-assessment");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var out = {
|
|
117
|
+
isFrontierModel: isFrontierModel,
|
|
118
|
+
isLargeFrontierDeveloper: isLargeFrontierDeveloper,
|
|
119
|
+
thresholds: { frontierFlops: FRONTIER_FLOP_THRESHOLD, largeDeveloperRevenueUsd: LARGE_DEVELOPER_REVENUE_USD },
|
|
120
|
+
obligations: obligations,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (opts.framework != null) {
|
|
124
|
+
if (typeof opts.framework !== "object") throw new FrontierProtocolError("frontier/bad-value", "frontierModelProtocol: framework must be an object");
|
|
125
|
+
var gaps = REQUIRED_FRAMEWORK_ELEMENTS.filter(function (el) { return !opts.framework[el]; });
|
|
126
|
+
out.frameworkGaps = gaps;
|
|
127
|
+
out.frameworkComplete = gaps.length === 0;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @primitive b.ai.frontierModelProtocol.incidentReport
|
|
134
|
+
* @signature b.ai.frontierModelProtocol.incidentReport(opts)
|
|
135
|
+
* @since 0.13.6
|
|
136
|
+
* @status stable
|
|
137
|
+
* @compliance ca-tfaia, soc2
|
|
138
|
+
* @related b.ai.frontierModelProtocol, b.ai.aedtBiasAudit
|
|
139
|
+
*
|
|
140
|
+
* Build a critical-safety-incident report and compute its notification
|
|
141
|
+
* deadline to the California Office of Emergency Services. <code>type</code>
|
|
142
|
+
* must be one of the Act's four categories; <code>discoveredAt</code> is when
|
|
143
|
+
* the incident was discovered. The deadline is 15 days from discovery, or 24
|
|
144
|
+
* hours when <code>imminentRiskToLife</code> is set. Returns the structured
|
|
145
|
+
* report with <code>dueAt</code> and <code>deadlineHours</code>. Throws
|
|
146
|
+
* <code>FrontierProtocolError</code> on an unknown type or bad timestamp.
|
|
147
|
+
*
|
|
148
|
+
* @opts
|
|
149
|
+
* type: string, // one of b.ai.frontierModelProtocol.INCIDENT_TYPES (required)
|
|
150
|
+
* discoveredAt: Date, // discovery time (Date or epoch-ms; default: now)
|
|
151
|
+
* imminentRiskToLife: boolean, // true → 24-hour deadline (default: false → 15 days)
|
|
152
|
+
* description: string, // free-text incident description (optional)
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* var r = b.ai.frontierModelProtocol.incidentReport({
|
|
156
|
+
* type: "loss-of-control-harm", discoveredAt: new Date("2026-06-01T00:00:00Z"),
|
|
157
|
+
* });
|
|
158
|
+
* r.deadlineHours; // → 360 (15 days)
|
|
159
|
+
* r.recipient; // → "California Office of Emergency Services"
|
|
160
|
+
*/
|
|
161
|
+
function incidentReport(opts) {
|
|
162
|
+
opts = opts || {};
|
|
163
|
+
if (typeof opts !== "object") throw new FrontierProtocolError("frontier/bad-opts", "incidentReport: opts must be an object");
|
|
164
|
+
if (!CRITICAL_INCIDENT_TYPES[opts.type]) throw new FrontierProtocolError("frontier/bad-incident-type", "incidentReport: type must be one of " + Object.keys(CRITICAL_INCIDENT_TYPES).join(", "));
|
|
165
|
+
|
|
166
|
+
var discoveredMs;
|
|
167
|
+
if (opts.discoveredAt == null) discoveredMs = Date.now();
|
|
168
|
+
else if (opts.discoveredAt instanceof Date) discoveredMs = opts.discoveredAt.getTime();
|
|
169
|
+
else discoveredMs = Number(opts.discoveredAt);
|
|
170
|
+
if (!isFinite(discoveredMs) || discoveredMs < 0) throw new FrontierProtocolError("frontier/bad-value", "incidentReport: discoveredAt must be a Date or non-negative epoch-ms");
|
|
171
|
+
|
|
172
|
+
var imminent = opts.imminentRiskToLife === true;
|
|
173
|
+
var windowMs = imminent ? IMMINENT_DEADLINE_MS : INCIDENT_DEADLINE_MS;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
type: opts.type,
|
|
177
|
+
typeDescription: CRITICAL_INCIDENT_TYPES[opts.type],
|
|
178
|
+
description: typeof opts.description === "string" ? opts.description : null,
|
|
179
|
+
imminentRiskToLife: imminent,
|
|
180
|
+
discoveredAt: new Date(discoveredMs).toISOString(),
|
|
181
|
+
dueAt: new Date(discoveredMs + windowMs).toISOString(),
|
|
182
|
+
deadlineHours: windowMs / C.TIME.hours(1),
|
|
183
|
+
// §22757.13: the routine report goes to OES within 15 days; an imminent
|
|
184
|
+
// risk of death or serious physical injury is reported to an applicable
|
|
185
|
+
// authority within 24 hours, which the operator selects for the incident.
|
|
186
|
+
recipient: imminent ? "An applicable authority with jurisdiction (e.g. law enforcement or a public-safety agency)" : "California Office of Emergency Services",
|
|
187
|
+
citation: "Cal. Bus. & Prof. Code §22757.13 (SB 53)",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
frontierModelProtocol.incidentReport = incidentReport;
|
|
192
|
+
frontierModelProtocol.INCIDENT_TYPES = Object.keys(CRITICAL_INCIDENT_TYPES);
|
|
193
|
+
frontierModelProtocol.REQUIRED_FRAMEWORK_ELEMENTS = REQUIRED_FRAMEWORK_ELEMENTS;
|
|
194
|
+
frontierModelProtocol.FrontierProtocolError = FrontierProtocolError;
|
|
195
|
+
|
|
196
|
+
module.exports = frontierModelProtocol;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.6",
|
|
4
|
+
"date": "2026-05-26",
|
|
5
|
+
"headline": "`b.ai.frontierModelProtocol` — California SB 53 frontier-AI obligations",
|
|
6
|
+
"summary": "b.ai.frontierModelProtocol assesses a developer's obligations under California's Transparency in Frontier Artificial Intelligence Act — SB 53, Cal. Bus. & Prof. Code §22757.10, effective 2026-01-01 — from a model's training compute and the developer's revenue. It reports whether the model crosses the frontier threshold (more than 10^26 training FLOPs), whether the developer is a large frontier developer (prior-year revenue, with affiliates, above $500M), and the resulting obligations: every frontier developer must report critical safety incidents and publish a transparency report, and a large frontier developer must additionally publish an annual safety framework and disclose its catastrophic-risk assessment. Passing a candidate safety framework reports which required elements (risk identification, mitigation, governance, cybersecurity, standards alignment) are missing. b.ai.frontierModelProtocol.incidentReport validates a critical-incident type against the Act's four categories and computes the notification deadline to the California Office of Emergency Services — 15 days from discovery, or 24 hours when there is an imminent risk of death or serious physical injury. The ca-tfaia compliance posture was already in the catalog.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.ai.frontierModelProtocol` — SB 53 threshold classification, obligations, and incident reporting",
|
|
13
|
+
"body": "`b.ai.frontierModelProtocol({ trainingFlops, annualRevenueUsd, framework? })` returns `isFrontierModel`, `isLargeFrontierDeveloper`, the `obligations` list, and (when a framework is supplied) its `frameworkGaps`. `b.ai.frontierModelProtocol.incidentReport({ type, discoveredAt, imminentRiskToLife? })` builds a critical-safety-incident report with the California OES recipient and a `dueAt` / `deadlineHours` computed from the 15-day (or 24-hour imminent-risk) statutory window; `type` must be one of the four categories in `INCIDENT_TYPES`. Throws `FrontierProtocolError` on malformed input."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.ai.frontierModelProtocol (California SB 53, Transparency in
|
|
4
|
+
* Frontier AI Act).
|
|
5
|
+
*
|
|
6
|
+
* Oracle: the statute's thresholds and deadlines (Cal. Bus. & Prof. Code
|
|
7
|
+
* §22757.10) — a frontier model exceeds 10^26 training FLOPs, a large frontier
|
|
8
|
+
* developer exceeds $500M revenue, and a critical-incident report is due to the
|
|
9
|
+
* CA OES within 15 days of discovery (24 hours when there is imminent risk to
|
|
10
|
+
* life). The four incident categories are enumerated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
var b = require("../../index");
|
|
14
|
+
var helpers = require("../helpers");
|
|
15
|
+
var check = helpers.check;
|
|
16
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
17
|
+
var fp = b.ai.frontierModelProtocol;
|
|
18
|
+
|
|
19
|
+
function testSurface() {
|
|
20
|
+
check("b.ai.frontierModelProtocol is a function", typeof fp === "function");
|
|
21
|
+
check("incidentReport is a function", typeof fp.incidentReport === "function");
|
|
22
|
+
check("INCIDENT_TYPES has the four categories", fp.INCIDENT_TYPES.length === 4);
|
|
23
|
+
check("REQUIRED_FRAMEWORK_ELEMENTS present", fp.REQUIRED_FRAMEWORK_ELEMENTS.length === 5);
|
|
24
|
+
check("FrontierProtocolError is a class", typeof fp.FrontierProtocolError === "function");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function testThresholds() {
|
|
28
|
+
var big = fp({ trainingFlops: 5e26, annualRevenueUsd: 1e9 });
|
|
29
|
+
check("above 10^26 FLOPs is a frontier model", big.isFrontierModel === true);
|
|
30
|
+
check("frontier + >$500M is a large frontier developer", big.isLargeFrontierDeveloper === true);
|
|
31
|
+
check("large developer gets all four obligations", big.obligations.length === 4 && big.obligations.indexOf("publish-annual-safety-framework") !== -1);
|
|
32
|
+
|
|
33
|
+
check("below 10^26 FLOPs is not a frontier model", fp({ trainingFlops: 1e25, annualRevenueUsd: 1e9 }).isFrontierModel === false);
|
|
34
|
+
check("exactly 10^26 FLOPs is not a frontier model (strict >)", fp({ trainingFlops: 1e26 }).isFrontierModel === false);
|
|
35
|
+
|
|
36
|
+
var smallCo = fp({ trainingFlops: 5e26, annualRevenueUsd: 1e8 });
|
|
37
|
+
check("frontier model at a small developer: frontier yes, large no", smallCo.isFrontierModel === true && smallCo.isLargeFrontierDeveloper === false);
|
|
38
|
+
check("small developer gets only the frontier obligations", smallCo.obligations.length === 2 && smallCo.obligations.indexOf("report-critical-safety-incidents") !== -1);
|
|
39
|
+
check("revenue defaults to 0 when omitted", fp({ trainingFlops: 5e26 }).isLargeFrontierDeveloper === false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function testFrameworkCheck() {
|
|
43
|
+
var gaps = fp({ trainingFlops: 5e26, annualRevenueUsd: 1e9, framework: { riskIdentification: 1, governance: 1 } });
|
|
44
|
+
check("framework gaps detected", JSON.stringify(gaps.frameworkGaps) === JSON.stringify(["riskMitigation", "cybersecurity", "standardsAlignment"]));
|
|
45
|
+
check("frameworkComplete false with gaps", gaps.frameworkComplete === false);
|
|
46
|
+
var complete = fp({ trainingFlops: 5e26, annualRevenueUsd: 1e9, framework: { riskIdentification: 1, riskMitigation: 1, governance: 1, cybersecurity: 1, standardsAlignment: 1 } });
|
|
47
|
+
check("frameworkComplete true when all elements present", complete.frameworkComplete === true && complete.frameworkGaps.length === 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function testIncidentReport() {
|
|
51
|
+
var r15 = fp.incidentReport({ type: "loss-of-control-harm", discoveredAt: new Date("2026-06-01T00:00:00Z") });
|
|
52
|
+
check("default deadline is 15 days (360h)", r15.deadlineHours === 360);
|
|
53
|
+
check("15-day dueAt computed", r15.dueAt === "2026-06-16T00:00:00.000Z");
|
|
54
|
+
check("routine report goes to CA OES", r15.recipient.indexOf("Office of Emergency Services") !== -1);
|
|
55
|
+
check("type description carried", r15.typeDescription.indexOf("Loss of control") !== -1);
|
|
56
|
+
|
|
57
|
+
var r24 = fp.incidentReport({ type: "weights-exfiltration-harm", discoveredAt: new Date("2026-06-01T00:00:00Z"), imminentRiskToLife: true });
|
|
58
|
+
check("imminent-risk deadline is 24 hours", r24.deadlineHours === 24);
|
|
59
|
+
check("24-hour dueAt computed", r24.dueAt === "2026-06-02T00:00:00.000Z");
|
|
60
|
+
// §22757.13: imminent risk routes to an applicable authority, not OES.
|
|
61
|
+
check("imminent-risk routes to an applicable authority, not OES", r24.recipient.indexOf("Office of Emergency Services") === -1 && r24.recipient.indexOf("applicable authority") !== -1);
|
|
62
|
+
// §22757.10(i): the weights category is death/bodily-injury only — no property.
|
|
63
|
+
check("weights-incident description excludes property", r24.typeDescription.toLowerCase().indexOf("property") === -1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function testErrors() {
|
|
67
|
+
check("unknown incident type throws", code(function () { fp.incidentReport({ type: "made-up" }); }) === "frontier/bad-incident-type");
|
|
68
|
+
check("bad discoveredAt throws", code(function () { fp.incidentReport({ type: "loss-of-control-harm", discoveredAt: -5 }); }) === "frontier/bad-value");
|
|
69
|
+
check("missing trainingFlops throws", code(function () { fp({}); }) === "frontier/bad-value");
|
|
70
|
+
check("negative trainingFlops throws", code(function () { fp({ trainingFlops: -1 }); }) === "frontier/bad-value");
|
|
71
|
+
check("unknown opt throws", code(function () { fp({ trainingFlops: 1e26, bogus: 1 }); }) === "frontier/bad-opts");
|
|
72
|
+
check("non-object framework throws", code(function () { fp({ trainingFlops: 5e26, framework: 7 }); }) === "frontier/bad-value");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function run() {
|
|
76
|
+
testSurface();
|
|
77
|
+
testThresholds();
|
|
78
|
+
testFrameworkCheck();
|
|
79
|
+
testIncidentReport();
|
|
80
|
+
testErrors();
|
|
81
|
+
}
|
|
82
|
+
module.exports = { run: run };
|
|
83
|
+
if (require.main === module) { run().then(function () { console.log("[ai-frontier-protocol] OK — " + helpers.getChecks() + " checks passed"); }, function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }); }
|
package/package.json
CHANGED