@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 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. |
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.36",
2
+ "version": "0.1.37",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
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=\"en\">\n" +
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\">Skip to content</a>\n" +
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> Open source · Apache 2.0</span>\n" +
154
- " <span class=\"utility-bar__msg\">Server-rendered HTML · post-quantum crypto on by default · zero npm runtime deps</span>\n" +
155
- " <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">Star on GitHub →</a>\n" +
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\">Search products</label>\n" +
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=\"Search the catalog\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\">\n" +
167
- " <button type=\"submit\">Search</button>\n" +
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=\"/\">Shop</a>\n" +
172
- " <a class=\"site-nav__link\" href=\"#framework\">Framework</a>\n" +
173
- " <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"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" +
174
- " <a class=\"cart-pill\" href=\"/cart\" aria-label=\"Cart, {{cart_count}} items\"><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" +
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\">Stay in the loop</p>\n" +
185
- " <h2 id=\"newsletter-title\">Get release notes the day they ship.</h2>\n" +
186
- " <p class=\"newsletter-band__lede\">No marketing emails. A single short note when there's a new framework release, a security advisory, or a primitive worth knowing about.</p>\n" +
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\">Email address</label>\n" +
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\">Subscribe</button>\n" +
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\">An open-source shop framework — server-rendered HTML, zero npm runtime dependencies, security defaults on.</p>\n" +
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>Shop</h4>\n" +
221
+ " <h4>{{footer_shop_heading}}</h4>\n" +
209
222
  " <ul>\n" +
210
- " <li><a href=\"/\">All products</a></li>\n" +
211
- " <li><a href=\"/collections\">Collections</a></li>\n" +
212
- " <li><a href=\"/categories\">Categories</a></li>\n" +
213
- " <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
214
- " <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
215
- " <li><a href=\"/compare\">Compare</a></li>\n" +
216
- " <li><a href=\"/cart\">Cart</a></li>\n" +
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>Framework</h4>\n" +
233
+ " <h4>{{footer_framework_heading}}</h4>\n" +
221
234
  " <ul>\n" +
222
- " <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">Source on GitHub</a></li>\n" +
223
- " <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">blamejs core</a></li>\n" +
224
- " <li><a href=\"/SECURITY.md\">Security policy</a></li>\n" +
225
- " <li><a href=\"/CHANGELOG.md\">Changelog</a></li>\n" +
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>Operators</h4>\n" +
242
+ " <h4>{{footer_operators_heading}}</h4>\n" +
230
243
  " <ul>\n" +
231
- " <li><a href=\"/account\">Account</a></li>\n" +
232
- " <li><a href=\"/orders\">Orders</a></li>\n" +
233
- " <li><a href=\"/admin\">Admin</a></li>\n" +
234
- " <li><a href=\"mailto:hello@blamejs.shop\">Contact</a></li>\n" +
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>&copy; {{year}} {{shop_name}} — built on blamejs · Apache 2.0 licensed.</p>\n" +
254
+ " <p>&copy; {{year}} {{shop_name}} — {{footer_copy_suffix}}</p>\n" +
241
255
  " <ul>\n" +
242
- " <li><a href=\"/SECURITY.md\">Security</a></li>\n" +
243
- " <li><a href=\"/privacy\">Privacy</a></li>\n" +
244
- " <li><a href=\"/terms\">Terms</a></li>\n" +
245
- " <li><a href=\"/cookies\">Manage cookies</a></li>\n" +
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
- return _render(LAYOUT, {
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: opts.cart_count == null ? 0 : opts.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
- }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
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
@@ -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
  };
@@ -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.5",
7
- "tag": "v0.13.5",
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.5",
4
- "createdAt": "2026-05-27T01:38:32.776Z",
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. &amp; 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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {