@blamejs/blamejs-shop 0.2.3 → 0.2.5

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/lib/storefront.js CHANGED
@@ -160,6 +160,7 @@ var LAYOUT =
160
160
  "</head>\n" +
161
161
  "<body>\n" +
162
162
  " <a class=\"skip-link\" href=\"#main\">{{skip_to_content}}</a>\n" +
163
+ "RAW_ANNOUNCEMENT_BAR" +
163
164
  "\n" +
164
165
  " <div class=\"utility-bar\" role=\"complementary\">\n" +
165
166
  " <div class=\"utility-bar__inner\">\n" +
@@ -262,6 +263,7 @@ var LAYOUT =
262
263
  " </footer>\n" +
263
264
  CONSENT_BANNER +
264
265
  "RAW_CONSENT_SCRIPT" +
266
+ "RAW_ANNOUNCEMENT_SCRIPT" +
265
267
  "</body>\n" +
266
268
  "</html>\n";
267
269
 
@@ -331,6 +333,128 @@ function _islandScript(name, opts) {
331
333
  (sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
332
334
  }
333
335
 
336
+ // ---- announcement bar --------------------------------------------------
337
+ //
338
+ // Sitewide operator promo/notice strip rendered at the top of every page
339
+ // (this container render + the worker/render/*.js LAYOUT mirror it). The
340
+ // active row is resolved per request from a short-TTL in-memory cache of
341
+ // the active announcements — NOT a per-request DB read — because the bar
342
+ // is page-top chrome on every route and the active set changes rarely
343
+ // (operator-managed). The cache is refreshed out-of-band (fire-and-forget)
344
+ // so a request never blocks on it; resolution is synchronous, which is why
345
+ // it can run in the same sync middleware that seeds the locale context
346
+ // (an async middleware's `enterWith` would not reach the handler).
347
+ //
348
+ // Dismissal mirrors the consent banner: the bar is always server-rendered;
349
+ // the `announcement.js` island hides any bar whose slug is listed in the
350
+ // plain `shop_ann_dismissed` cookie and sets that cookie on dismiss. A
351
+ // no-JS POST /announcements/:slug/dismiss records the durable dismissal +
352
+ // sets the same cookie. The cookie-driven hide is what keeps a dismissed
353
+ // bar from reappearing on an edge-cached page (the slug is in the markup;
354
+ // the island removes it client-side per the visitor's cookie).
355
+ var ANNOUNCEMENT_DISMISS_COOKIE = "shop_ann_dismissed";
356
+ var _BAR_THEME_RANK = { urgency: 3, promo: 2, info: 1, success: 0 };
357
+ var _BAR_TTL_MS = 30000;
358
+ var _annCache = { rows: [], at: 0, inflight: false };
359
+
360
+ // Refresh the active-announcement cache if it's older than the TTL. Async
361
+ // + fire-and-forget: the caller never awaits it, so a slow D1 read can't
362
+ // stall a page render — the request resolves against whatever the cache
363
+ // last held (empty on a cold first request, then populated within the TTL).
364
+ function _refreshAnnouncementCache(announcementBar) {
365
+ if (!announcementBar) return;
366
+ var now = Date.now();
367
+ if (_annCache.inflight) return;
368
+ if (now - _annCache.at < _BAR_TTL_MS && _annCache.at !== 0) return;
369
+ _annCache.inflight = true;
370
+ Promise.resolve()
371
+ .then(function () { return announcementBar.listAnnouncements({ active_only: true }); })
372
+ .then(function (rows) { _annCache.rows = Array.isArray(rows) ? rows : []; _annCache.at = Date.now(); })
373
+ .catch(function () { /* drop-silent — keep serving the prior cache */ })
374
+ .then(function () { _annCache.inflight = false; });
375
+ }
376
+
377
+ // Synchronous pick over the cached active rows: drop announcements whose
378
+ // audience the viewer doesn't match (segment is never matched here — the
379
+ // console doesn't offer it) and return the highest theme-rank survivor
380
+ // (urgency > promo > info > success), ties broken by the cache's own order
381
+ // (updated_at DESC, slug ASC from listAnnouncements).
382
+ //
383
+ // Dismissal is NOT filtered here: the bar is rendered identically for every
384
+ // viewer of an audience (so an edge-cached response is correct for all) and
385
+ // the announcement island hides any bar whose slug is in the visitor's
386
+ // `shop_ann_dismissed` cookie — exactly the consent-banner pattern. This
387
+ // keeps the container + edge renders byte-identical and the shared edge
388
+ // cache key (URL) sound.
389
+ function _resolveActiveAnnouncement(viewerKind) {
390
+ var rows = _annCache.rows;
391
+ if (!rows || !rows.length) return null;
392
+ var best = null;
393
+ var bestRank = -1;
394
+ for (var i = 0; i < rows.length; i += 1) {
395
+ var row = rows[i];
396
+ if (row.audience === "logged_in" && viewerKind !== "logged_in") continue;
397
+ if (row.audience === "guest" && viewerKind !== "guest") continue;
398
+ if (row.audience === "segment") continue;
399
+ var rank = _BAR_THEME_RANK[row.theme];
400
+ if (rank == null) rank = -1;
401
+ if (rank > bestRank) { best = row; bestRank = rank; }
402
+ }
403
+ return best;
404
+ }
405
+
406
+ // Parse the plain `shop_ann_dismissed` cookie into an array of slugs. The
407
+ // cookie is a comma-separated slug list (slugs are [a-z0-9-], so no
408
+ // escaping is needed); anything that isn't a well-shaped slug is ignored.
409
+ function _readDismissedSlugs(req) {
410
+ var raw = _readCookie(req, ANNOUNCEMENT_DISMISS_COOKIE);
411
+ if (!raw || typeof raw !== "string") return [];
412
+ var out = [];
413
+ var parts = raw.split(",");
414
+ for (var i = 0; i < parts.length; i += 1) {
415
+ var s = parts[i].trim();
416
+ if (s && /^[a-z0-9-]{1,64}$/.test(s) && out.indexOf(s) === -1) out.push(s);
417
+ }
418
+ return out;
419
+ }
420
+
421
+ // Render the bar markup for a hydrated announcement row. Returns "" for a
422
+ // null row so a no-announcement page renders unchanged. `currentColor`-free
423
+ // — the theme color comes from the `--<theme>` modifier class in main.css.
424
+ function _buildAnnouncementBar(row) {
425
+ if (!row) return "";
426
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
427
+ var theme = _BAR_THEME_RANK[row.theme] != null ? row.theme : "info";
428
+ var slug = esc(row.slug);
429
+ var link = "";
430
+ if (row.link_url && row.link_label) {
431
+ // Defense-in-depth at the href sink: only emit the link for an https://
432
+ // URL or a /-rooted absolute path (not protocol-relative `//`). The
433
+ // lib's `_linkUrl` already enforces exactly this at write time, but
434
+ // re-checking here means a `javascript:` / `data:` scheme can never
435
+ // reach the rendered href even if a row ever arrived by another path —
436
+ // HTML-escaping alone does not neutralise those schemes.
437
+ var href = String(row.link_url);
438
+ if (/^https:\/\//i.test(href) || (href.charAt(0) === "/" && href.charAt(1) !== "/")) {
439
+ link = " <a class=\"announcement-bar__link\" href=\"" + esc(href) + "\">" + esc(row.link_label) + "</a>";
440
+ }
441
+ }
442
+ var dismiss = "";
443
+ if (row.dismissible) {
444
+ dismiss =
445
+ "<form class=\"announcement-bar__dismiss\" method=\"post\" action=\"/announcements/" + slug + "/dismiss\">" +
446
+ "<input type=\"hidden\" name=\"return_to\" value=\"/\" data-announcement-return>" +
447
+ "<button type=\"submit\" class=\"announcement-bar__x\" aria-label=\"Dismiss this announcement\">&times;</button>" +
448
+ "</form>";
449
+ }
450
+ return "<aside class=\"announcement-bar announcement-bar--" + theme + "\" role=\"region\" aria-label=\"Store announcement\" data-announcement-slug=\"" + slug + "\">" +
451
+ "<div class=\"announcement-bar__inner\">" +
452
+ "<p class=\"announcement-bar__msg\">" + esc(row.message) + link + "</p>" +
453
+ dismiss +
454
+ "</div>" +
455
+ "</aside>";
456
+ }
457
+
334
458
  // Multi-currency display switcher — a GET form in the footer listing the
335
459
  // operator's display currencies. Selecting one POSTs to /currency, which
336
460
  // sets the sealed `shop_ccy` cookie and redirects back. The currently
@@ -441,6 +565,17 @@ function _wrap(opts) {
441
565
  var localeCtx = (opts.locale_ctx && opts.locale_ctx.chrome)
442
566
  ? opts.locale_ctx
443
567
  : ((storeCtx && storeCtx.chrome) ? storeCtx : _DEFAULT_LOCALE_CTX);
568
+ // Announcement bar — the active row is resolved by the sync request
569
+ // middleware and carried on the locale ALS store; an explicit
570
+ // `opts.announcement` (a renderer or unit test threading its own) wins.
571
+ // Absent both, no bar renders.
572
+ var announcementRow = (opts.announcement !== undefined)
573
+ ? opts.announcement
574
+ : ((storeCtx && storeCtx.announcement) || null);
575
+ var announcementBarHtml = _buildAnnouncementBar(announcementRow);
576
+ var announcementScript = (announcementRow && announcementRow.dismissible)
577
+ ? _islandScript("announcement.js", { id: "announcement-island" })
578
+ : "";
444
579
  var chrome = localeCtx.chrome;
445
580
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
446
581
  // The cart aria-label carries the count: when the resolved string is
@@ -481,7 +616,9 @@ function _wrap(opts) {
481
616
 
482
617
  return _render(LAYOUT, vars)
483
618
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
619
+ .replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
484
620
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
621
+ .replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
485
622
  .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
486
623
  .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
487
624
  .replace("RAW_BODY_PLACEHOLDER", opts.body);
@@ -4519,6 +4656,85 @@ function renderAccount(opts) {
4519
4656
  });
4520
4657
  }
4521
4658
 
4659
+ // ---- customer survey page ----------------------------------------------
4660
+
4661
+ // Render one survey question as a fieldset. Rating → a 0/1..max radio
4662
+ // scale; select → radio options; free_text → a textarea. Field name is
4663
+ // `q_<id>`; the post handler maps it back. All operator-authored strings
4664
+ // are HTML-escaped at the sink.
4665
+ function _surveyQuestion(q) {
4666
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
4667
+ var name = "q_" + esc(q.id);
4668
+ var req = q.required ? " <abbr class=\"survey-req\" title=\"Required\">*</abbr>" : "";
4669
+ var control = "";
4670
+ if (q.kind === "rating") {
4671
+ var lo = (q.max >= 9) ? 0 : 1; // 0..10 for NPS-scale, else 1..max
4672
+ var scale = "";
4673
+ for (var n = lo; n <= q.max; n += 1) {
4674
+ scale += "<label class=\"survey-scale__opt\"><input type=\"radio\" name=\"" + name + "\" value=\"" + n + "\"" + (q.required ? " required" : "") + "><span>" + n + "</span></label>";
4675
+ }
4676
+ control = "<div class=\"survey-scale\">" + scale + "</div>";
4677
+ } else if (q.kind === "select") {
4678
+ var opts = "";
4679
+ for (var k = 0; k < q.options.length; k += 1) {
4680
+ var ov = esc(q.options[k]);
4681
+ opts += "<label class=\"survey-opt\"><input type=\"radio\" name=\"" + name + "\" value=\"" + ov + "\"" + (q.required ? " required" : "") + "> " + ov + "</label>";
4682
+ }
4683
+ control = "<div class=\"survey-opts\">" + opts + "</div>";
4684
+ } else {
4685
+ control = "<textarea name=\"" + name + "\" maxlength=\"" + (q.max || 2000) + "\"" + (q.required ? " required" : "") + "></textarea>";
4686
+ }
4687
+ return "<fieldset class=\"survey-q\"><legend>" + esc(q.label) + req + "</legend>" + control + "</fieldset>";
4688
+ }
4689
+
4690
+ // The token survey page. `state` selects the panel: form (answerable),
4691
+ // thankyou (just submitted), responded (already done), expired, closed, or
4692
+ // notfound. The token is rendered into the form action so the POST carries
4693
+ // it back (it's path-segment-safe — 43 base64url chars).
4694
+ function renderSurveyPage(opts) {
4695
+ opts = opts || {};
4696
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
4697
+ var state = opts.state || "notfound";
4698
+ var body;
4699
+ if (state === "form") {
4700
+ var survey = opts.survey || {};
4701
+ var qs = (survey.questions || []).map(_surveyQuestion).join("");
4702
+ var notice = opts.notice ? "<p class=\"form-notice\">" + esc(opts.notice) + "</p>" : "";
4703
+ body =
4704
+ "<section class=\"survey-page\"><div class=\"survey-page__inner\">" +
4705
+ "<p class=\"eyebrow\">Your feedback</p>" +
4706
+ "<h1 class=\"survey-page__title\">" + esc(survey.title) + "</h1>" +
4707
+ notice +
4708
+ "<form class=\"survey-form\" method=\"post\" action=\"/survey/" + esc(opts.token) + "\">" +
4709
+ qs +
4710
+ "<div class=\"survey-form__actions\"><button class=\"btn-primary\" type=\"submit\">Submit feedback</button></div>" +
4711
+ "</form>" +
4712
+ "</div></section>";
4713
+ } else {
4714
+ var heads = {
4715
+ thankyou: ["Thank you", "Your feedback's in — we appreciate you taking the time."],
4716
+ responded: ["Already answered", "This feedback link has already been used. Thank you."],
4717
+ expired: ["This link has expired", "The feedback window for this survey has closed."],
4718
+ closed: ["This survey is closed", "This feedback link is no longer active."],
4719
+ notfound: ["Survey not found", "This feedback link isn't valid. Check the link from your email."],
4720
+ };
4721
+ var h = heads[state] || heads.notfound;
4722
+ body =
4723
+ "<section class=\"survey-page\"><div class=\"survey-page__inner survey-page__inner--msg\">" +
4724
+ "<h1 class=\"survey-page__title\">" + esc(h[0]) + "</h1>" +
4725
+ "<p class=\"survey-page__lede\">" + esc(h[1]) + "</p>" +
4726
+ "<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
4727
+ "</div></section>";
4728
+ }
4729
+ return _wrap({
4730
+ title: opts.title || "Survey",
4731
+ shop_name: opts.shop_name || "blamejs.shop",
4732
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4733
+ theme_css: opts.theme_css,
4734
+ body: body,
4735
+ });
4736
+ }
4737
+
4522
4738
  function mount(router, deps) {
4523
4739
  if (!router || typeof router.get !== "function") throw new TypeError("storefront.mount: router with .get() required");
4524
4740
  if (!deps || !deps.catalog || !deps.cart) throw new TypeError("storefront.mount: deps.catalog + deps.cart required");
@@ -4853,6 +5069,149 @@ function mount(router, deps) {
4853
5069
  });
4854
5070
  }
4855
5071
 
5072
+ // Announcement-bar resolution — synchronous, so its `enterWith` reaches
5073
+ // the page handler (an async middleware's would not). Runs AFTER the
5074
+ // locale middleware so it can extend that request's ALS context rather
5075
+ // than replace it. The active set comes from a short-TTL in-memory cache
5076
+ // refreshed out-of-band here (fire-and-forget — never blocks the render);
5077
+ // resolution itself reads the cache + the dismissed-slug cookie + a
5078
+ // coarse logged-in/guest signal (auth-cookie presence — exact identity
5079
+ // isn't needed to bucket audience). Best-effort: any failure drops the
5080
+ // bar, never the page.
5081
+ if (typeof router.use === "function" && deps.announcementBar) {
5082
+ router.use(function announcementMiddleware(req, _res, next) {
5083
+ try {
5084
+ _refreshAnnouncementCache(deps.announcementBar);
5085
+ var viewerKind = _cookieJar().read(req, AUTH_COOKIE_NAME) ? "logged_in" : "guest";
5086
+ var row = _resolveActiveAnnouncement(viewerKind);
5087
+ var cur = _localeAls.getStore() || {};
5088
+ _localeAls.enterWith(Object.assign({}, cur, { announcement: row }));
5089
+ } catch (_e) { /* drop-silent — no bar this request */ }
5090
+ next();
5091
+ });
5092
+
5093
+ // Dismiss an announcement (no-JS path; the island intercepts the click
5094
+ // and stays on-page). Records the durable dismissal keyed on the
5095
+ // session id when one is present, always appends the slug to the plain
5096
+ // `shop_ann_dismissed` cookie (the cookie is what hides the bar on a
5097
+ // cached edge response), and 303-redirects to a validated same-origin
5098
+ // return path. Unknown slug / no session still sets the cookie + redirects
5099
+ // — a hostile or stale slug can never 500 the route.
5100
+ router.post("/announcements/:slug/dismiss", async function (req, res) {
5101
+ var slug = (req.params && typeof req.params.slug === "string") ? req.params.slug : "";
5102
+ var body = req.body || {};
5103
+ var to = (typeof body.return_to === "string") ? body.return_to : "/";
5104
+ if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
5105
+ to = "/";
5106
+ }
5107
+ if (/^[a-z0-9-]{1,64}$/.test(slug)) {
5108
+ // Durable record (best-effort — keyed on the cart session id; absent
5109
+ // a session the cookie still carries the dismissal for this browser).
5110
+ try {
5111
+ var sid = _readSidCookie(req);
5112
+ if (sid) await deps.announcementBar.recordDismissal({ slug: slug, session_id: sid });
5113
+ } catch (_e) { /* slug not found / no session — cookie still set below */ }
5114
+ // Append the slug to the dismissed-cookie set (deduped, capped).
5115
+ var slugs = _readDismissedSlugs(req);
5116
+ if (slugs.indexOf(slug) === -1) slugs.push(slug);
5117
+ if (slugs.length > 50) slugs = slugs.slice(slugs.length - 50);
5118
+ _cookieJar().write(res, ANNOUNCEMENT_DISMISS_COOKIE, slugs.join(","), {
5119
+ expires: new Date(Date.now() + b.constants.TIME.days(180)), httpOnly: false,
5120
+ });
5121
+ }
5122
+ res.status(303);
5123
+ res.setHeader && res.setHeader("location", to);
5124
+ return res.end ? res.end() : res.send("");
5125
+ });
5126
+ }
5127
+
5128
+ // ---- customer survey (token-gated) ----------------------------------
5129
+ // The invitation token IS the access — no login. GET renders the survey
5130
+ // (or a state notice); POST records the response. Container-only (the
5131
+ // token page is never edge-cached). Resilient: an unknown/garbage token,
5132
+ // a used/expired/closed invitation, and a missing surveys table all
5133
+ // resolve to a clean state page, never a 500.
5134
+ if (deps.customerSurveys) {
5135
+ var _surveyCtx = function (_req) {
5136
+ return {
5137
+ shop_name: (deps.config && deps.config.shop_name) || "blamejs.shop",
5138
+ cart_count: 0,
5139
+ theme_css: (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL,
5140
+ };
5141
+ };
5142
+
5143
+ router.get("/survey/:token", async function (req, res) {
5144
+ var token = (req.params && req.params.token) || "";
5145
+ var state = "notfound", survey = null;
5146
+ try {
5147
+ var preview = await deps.customerSurveys.previewByToken(token);
5148
+ if (preview) {
5149
+ survey = preview.survey;
5150
+ var inv = preview.invitation;
5151
+ if (inv.status === "responded") state = "responded";
5152
+ else if (inv.status === "closed") state = "closed";
5153
+ else if (inv.status === "expired" || Number(inv.expires_at) < Date.now()) state = "expired";
5154
+ else state = "form";
5155
+ }
5156
+ } catch (_e) { state = "notfound"; }
5157
+ var ctx = _surveyCtx(req);
5158
+ _send(res, state === "notfound" ? 404 : 200, renderSurveyPage({
5159
+ state: state, survey: survey, token: token,
5160
+ shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
5161
+ }));
5162
+ });
5163
+
5164
+ router.post("/survey/:token", async function (req, res) {
5165
+ var token = (req.params && req.params.token) || "";
5166
+ var body = req.body || {};
5167
+ var ctx = _surveyCtx(req);
5168
+ // Resolve the survey first so we can re-render the form on a
5169
+ // validation error (and map the token to its questions).
5170
+ var preview = null;
5171
+ try { preview = await deps.customerSurveys.previewByToken(token); }
5172
+ catch (_e) { preview = null; }
5173
+ if (!preview) {
5174
+ return _send(res, 404, renderSurveyPage({ state: "notfound", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5175
+ }
5176
+ // Build the answers object from the q_<id> fields, typed per question
5177
+ // kind (rating → integer, select/free_text → string). Blank fields are
5178
+ // omitted so optional questions stay unanswered.
5179
+ var answers = {};
5180
+ var questions = preview.survey.questions || [];
5181
+ for (var i = 0; i < questions.length; i += 1) {
5182
+ var q = questions[i];
5183
+ var raw = body["q_" + q.id];
5184
+ if (raw == null || raw === "") continue;
5185
+ if (q.kind === "rating") {
5186
+ var n = parseInt(raw, 10);
5187
+ if (isFinite(n)) answers[q.id] = n;
5188
+ } else {
5189
+ answers[q.id] = String(raw);
5190
+ }
5191
+ }
5192
+ try {
5193
+ await deps.customerSurveys.submitResponse({ token: token, answers: answers });
5194
+ } catch (e) {
5195
+ // Known terminal states → the matching notice; a validation
5196
+ // TypeError → re-render the form with the cleaned message.
5197
+ var code = e && e.code;
5198
+ if (code === "SURVEY_INVITATION_ALREADY_RESPONDED") return _send(res, 200, renderSurveyPage({ state: "responded", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5199
+ if (code === "SURVEY_INVITATION_EXPIRED") return _send(res, 200, renderSurveyPage({ state: "expired", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5200
+ if (code === "SURVEY_INVITATION_CLOSED") return _send(res, 200, renderSurveyPage({ state: "closed", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5201
+ if (code === "SURVEY_INVITATION_NOT_FOUND") return _send(res, 404, renderSurveyPage({ state: "notfound", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5202
+ if (e instanceof TypeError) {
5203
+ return _send(res, 400, renderSurveyPage({
5204
+ state: "form", survey: preview.survey, token: token,
5205
+ notice: (e.message || "Please check your answers.").replace(/^customerSurveys[.:]\s*/, ""),
5206
+ shop_name: ctx.shop_name, theme_css: ctx.theme_css,
5207
+ }));
5208
+ }
5209
+ throw e;
5210
+ }
5211
+ _send(res, 200, renderSurveyPage({ state: "thankyou", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
5212
+ });
5213
+ }
5214
+
4856
5215
  // Persist a locale choice. A GET form (works with JS off) from the
4857
5216
  // footer switcher submits `lang` (the chosen tag) + `to` (the path to
4858
5217
  // return to). We validate the tag against the active locale set, set
@@ -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.14",
7
- "tag": "v0.13.14",
6
+ "version": "0.13.16",
7
+ "tag": "v0.13.16",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.16 (2026-05-27) — **`b.mail.agent` docs now describe the facade accurately, and not-yet-wired verbs point to the primitive to use.** b.mail.agent's module documentation claimed it was "the standardization contract for every mail protocol" that JMAP / IMAP / POP3 all route through — but no protocol server actually dispatches through the agent (the framework's own JMAP EmailSubmission handler composes b.mail.send.deliver directly), and the compose / send / reply / forward, sieve.list / sieve.activate, identity / vacation / mdn.* and export / job / import verbs throw mail-agent/not-implemented. The docs are corrected to describe what the agent is: a mailbox-access facade (RBAC + posture + audit + dispatch around a mail store) whose read surface plus the mailbox-mutation and Sieve-upload methods are wired, with the remaining verbs not yet routed through it. Those verbs' error message now names the underlying primitive to compose directly (b.mail.send.deliver, b.mail.sieve, b.mailMdn, …) instead of citing a version tag that had long passed. The public WIRED_AT export (a method→version map that no longer reflected reality) is replaced by COMPOSE_HINT (a method→primitive-to-compose map). No behaviour change: the same methods are wired or throw exactly as before. **Changed:** *`b.mail.agent` documentation corrected; not-implemented errors point to the primitive to compose* — The `@module` / `@card` no longer claim the agent is the universal protocol-dispatch contract — it's documented as a mailbox-access facade with a wired read + mutation + Sieve-upload surface, and the compose/send/identity/vacation/MDN/export verbs documented as not yet routed through it (compose the underlying primitive directly until a protocol server adopts the agent). The `mail-agent/not-implemented` error now names that primitive (e.g. `b.mail.send.deliver`) rather than a passed version tag. **Removed:** *`b.mail.agent.WIRED_AT` export replaced by `COMPOSE_HINT`* — The `WIRED_AT` export mapped each method to a framework version that was supposed to "light it up" — versions that have all shipped without the wiring, so the map was misleading. It is replaced by `COMPOSE_HINT`, mapping each not-yet-wired method to the primitive an operator composes directly. Operators reading `b.mail.agent.WIRED_AT` should read `b.mail.agent.COMPOSE_HINT` instead (pre-1.0: no compatibility shim).
12
+
13
+ - v0.13.15 (2026-05-27) — **Corrected more source citations and made deferred/reserved options honest in their docs.** A second accuracy pass over source threat-annotations and option docs. Three citation corrections: the base64url strict-decode guard cited CVE-2022-0235 (which is actually a node-fetch cookie-leak, unrelated) — it now names the weakness class it defends (CWE-347 / CWE-1286 signature canonicalization); the glob consecutive-wildcard ReDoS cap cited the wrong library (the CVE-2026-26996 ReDoS is minimatch, not picomatch — the adjacent picomatch one is CVE-2026-33671); and CVE-2026-32178 is reframed to the CWE-138 header-injection-spoofing class the public record actually documents (and dropped from the end-of-data SMTP-smuggling list, which is a different class). Several options/statuses are now honest about not-yet-implemented surface: b.archive.read.zip.fromTrustedStream is marked experimental (its methods throw and its options aren't honored yet — the example now shows the supported buffer-then-random-access path); b.acme revokeCert's useCertKey / certPrivateKey are marked reserved (the cert-key path throws; account-key signing is the supported default); and a stale message claiming passkey break-glass factors were a future feature is removed (passkeys are a live allowed factor). No runtime behaviour changes beyond message/doc text. **Changed:** *Deferred / reserved surface now documented honestly* — `b.archive.read.zip.fromTrustedStream` is marked `experimental` — its `inspect`/`entries`/`extract` throw and its `bombPolicy`/`audit` options aren't honored yet; the documented example now shows the supported path (buffer the stream, then use the random-access reader). `b.acme` `revokeCert`'s `useCertKey` / `certPrivateKey` options are marked reserved (the cert-key-signed-revocation path throws; account-key signing, the default, covers mainstream CAs). A `b.breakGlass` policy error and comment that called passkey factors a future feature are corrected — passkeys are a live allowed factor. **Fixed:** *Corrected misattributed CVE citations in source threat-annotations* — `b.crypto.fromBase64Url`'s strict-decode guard cited CVE-2022-0235 (a node-fetch header-leak, unrelated to base64/JWT decoding); it now cites the weakness class it actually defends — CWE-347 / CWE-1286 signature canonicalization. `b.guardRegex`'s consecutive-`*` cap attributed CVE-2026-26996 to picomatch; that ReDoS is in minimatch (the picomatch ReDoS it also defends is CVE-2026-33671) — the library name is corrected. CVE-2026-32178 is reframed to the CWE-138 header-injection spoofing class the public advisory documents, and removed from the end-of-data SMTP-smuggling trio (a distinct class). No behaviour change — the defenses are unchanged.
14
+
11
15
  - v0.13.14 (2026-05-27) — **DNSSEC chain validation now bounds KeyTrap (CVE-2023-50387) amplification with hard caps.** b.network.dns.dnssec.verifyChain tried every DNSKEY whose 16-bit key tag matched an RRSIG, with no cap on how many candidates or total signature verifications a single response could drive. A hostile zone publishing many DNSKEYs sharing one key tag (plus matching RRSIGs) could force O(keys x signatures) full public-key verifications from one query — the KeyTrap denial-of-service (CVE-2023-50387). Validation is now bounded by non-configurable caps that match the BIND / Unbound mitigations: at most 4 same-tag candidate keys are tried per RRSIG, at most 64 DNSKEYs per zone link and 16 DS records per delegation are accepted, the chain is at most 128 links deep, and the whole response is held to a signature-validation budget that scales with chain depth (so a legitimate deep delegation is never false-rejected while bounded collisions stay bounded); exceeding any of these refuses the response rather than performing the work. Separately, a domain name that encodes to more than 255 octets is now refused at canonicalization (RFC 1035 §2.3.4), which also bounds the NSEC3 closest-encloser label enumeration, and the NSEC3 iteration ceiling is lowered from 500 to 150 to match the BIND 9.16.33+ / Unbound 1.17.1 fix for the sibling CVE-2023-50868. **Security:** *`verifyChain` caps colliding-key fan-out and total signature validations (KeyTrap / CVE-2023-50387)* — A zone advertising many same-key-tag DNSKEYs and RRSIGs can no longer drive unbounded public-key verifications. New refusals: `dnssec/too-many-colliding-keys` (>4 same-tag candidates per RRSIG), `dnssec/too-many-dnskeys` (>64 DNSKEYs per zone link), `dnssec/too-many-ds` (>16 DS records per delegation), `dnssec/too-many-links` (chain deeper than 128), and `dnssec/validation-budget-exceeded` (signature validations beyond the depth-scaled budget). The caps are intentionally non-configurable — they sit well above any legitimate zone, and the budget scales with chain depth so deep delegations validate normally. · *Domain-name octet cap + lower NSEC3 iteration ceiling* — A name that canonicalizes to more than 255 octets is refused (`dnssec/bad-name`, RFC 1035 §2.3.4), which bounds the per-label NSEC3 closest-encloser enumeration (CVE-2023-50868 class). The default NSEC3 iteration ceiling drops from 500 to 150, matching the BIND 9.16.33+ / Unbound 1.17.1 post-CVE defaults (RFC 9276 recommends 0).
12
16
 
13
17
  - v0.13.13 (2026-05-27) — **Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment.** b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow") for Linux-only targets. **Security:** *`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)* — Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `"allow"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate).
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.14",
4
- "createdAt": "2026-05-27T12:40:36.809Z",
3
+ "frameworkVersion": "0.13.16",
4
+ "createdAt": "2026-05-27T16:55:42.429Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -37552,54 +37552,17 @@
37552
37552
  "agent": {
37553
37553
  "type": "object",
37554
37554
  "members": {
37555
- "HEAVY_METHODS": {
37556
- "type": "object",
37557
- "members": {
37558
- "export": {
37559
- "type": "primitive",
37560
- "valueType": "boolean"
37561
- },
37562
- "search": {
37563
- "type": "primitive",
37564
- "valueType": "boolean"
37565
- }
37566
- }
37567
- },
37568
- "MailAgentError": {
37569
- "type": "function",
37570
- "arity": 4
37571
- },
37572
- "SCOPE_FOR_METHOD": {
37555
+ "COMPOSE_HINT": {
37573
37556
  "type": "object",
37574
37557
  "members": {
37575
37558
  "compose": {
37576
37559
  "type": "primitive",
37577
37560
  "valueType": "string"
37578
37561
  },
37579
- "delete": {
37580
- "type": "primitive",
37581
- "valueType": "string"
37582
- },
37583
37562
  "export": {
37584
37563
  "type": "primitive",
37585
37564
  "valueType": "string"
37586
37565
  },
37587
- "expunge": {
37588
- "type": "primitive",
37589
- "valueType": "string"
37590
- },
37591
- "fetch": {
37592
- "type": "primitive",
37593
- "valueType": "string"
37594
- },
37595
- "flag": {
37596
- "type": "primitive",
37597
- "valueType": "string"
37598
- },
37599
- "folders": {
37600
- "type": "primitive",
37601
- "valueType": "string"
37602
- },
37603
37566
  "forward": {
37604
37567
  "type": "primitive",
37605
37568
  "valueType": "string"
@@ -37628,56 +37591,73 @@
37628
37591
  "type": "primitive",
37629
37592
  "valueType": "string"
37630
37593
  },
37631
- "move": {
37594
+ "reply": {
37632
37595
  "type": "primitive",
37633
37596
  "valueType": "string"
37634
37597
  },
37635
- "quota": {
37598
+ "send": {
37636
37599
  "type": "primitive",
37637
37600
  "valueType": "string"
37638
37601
  },
37639
- "reply": {
37602
+ "sieve.activate": {
37640
37603
  "type": "primitive",
37641
37604
  "valueType": "string"
37642
37605
  },
37643
- "search": {
37606
+ "sieve.list": {
37644
37607
  "type": "primitive",
37645
37608
  "valueType": "string"
37646
37609
  },
37647
- "send": {
37610
+ "vacation.set": {
37648
37611
  "type": "primitive",
37649
37612
  "valueType": "string"
37613
+ }
37614
+ }
37615
+ },
37616
+ "HEAVY_METHODS": {
37617
+ "type": "object",
37618
+ "members": {
37619
+ "export": {
37620
+ "type": "primitive",
37621
+ "valueType": "boolean"
37650
37622
  },
37651
- "sieve.activate": {
37623
+ "search": {
37624
+ "type": "primitive",
37625
+ "valueType": "boolean"
37626
+ }
37627
+ }
37628
+ },
37629
+ "MailAgentError": {
37630
+ "type": "function",
37631
+ "arity": 4
37632
+ },
37633
+ "SCOPE_FOR_METHOD": {
37634
+ "type": "object",
37635
+ "members": {
37636
+ "compose": {
37652
37637
  "type": "primitive",
37653
37638
  "valueType": "string"
37654
37639
  },
37655
- "sieve.list": {
37640
+ "delete": {
37656
37641
  "type": "primitive",
37657
37642
  "valueType": "string"
37658
37643
  },
37659
- "sieve.put": {
37644
+ "export": {
37660
37645
  "type": "primitive",
37661
37646
  "valueType": "string"
37662
37647
  },
37663
- "thread": {
37648
+ "expunge": {
37664
37649
  "type": "primitive",
37665
37650
  "valueType": "string"
37666
37651
  },
37667
- "vacation.set": {
37652
+ "fetch": {
37668
37653
  "type": "primitive",
37669
37654
  "valueType": "string"
37670
- }
37671
- }
37672
- },
37673
- "WIRED_AT": {
37674
- "type": "object",
37675
- "members": {
37676
- "compose": {
37655
+ },
37656
+ "flag": {
37677
37657
  "type": "primitive",
37678
37658
  "valueType": "string"
37679
37659
  },
37680
- "export": {
37660
+ "folders": {
37681
37661
  "type": "primitive",
37682
37662
  "valueType": "string"
37683
37663
  },
@@ -37709,10 +37689,22 @@
37709
37689
  "type": "primitive",
37710
37690
  "valueType": "string"
37711
37691
  },
37692
+ "move": {
37693
+ "type": "primitive",
37694
+ "valueType": "string"
37695
+ },
37696
+ "quota": {
37697
+ "type": "primitive",
37698
+ "valueType": "string"
37699
+ },
37712
37700
  "reply": {
37713
37701
  "type": "primitive",
37714
37702
  "valueType": "string"
37715
37703
  },
37704
+ "search": {
37705
+ "type": "primitive",
37706
+ "valueType": "string"
37707
+ },
37716
37708
  "send": {
37717
37709
  "type": "primitive",
37718
37710
  "valueType": "string"
@@ -37729,6 +37721,10 @@
37729
37721
  "type": "primitive",
37730
37722
  "valueType": "string"
37731
37723
  },
37724
+ "thread": {
37725
+ "type": "primitive",
37726
+ "valueType": "string"
37727
+ },
37732
37728
  "vacation.set": {
37733
37729
  "type": "primitive",
37734
37730
  "valueType": "string"