@blamejs/blamejs-shop 0.5.3 → 0.5.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
@@ -6230,6 +6230,8 @@ var SUBSCRIPTION_OK = {
6230
6230
  quantity: "Quantity updated.",
6231
6231
  frequency: "Delivery frequency updated.",
6232
6232
  reactivated: "Your subscription has been reactivated.",
6233
+ plan_changed: "Your plan change is set.",
6234
+ change_canceled: "Your scheduled plan change was canceled.",
6233
6235
  };
6234
6236
  function _subscriptionOkCopy(kind) {
6235
6237
  return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_OK, kind) ? SUBSCRIPTION_OK[kind] : null;
@@ -6250,6 +6252,12 @@ var SUBSCRIPTION_ERR = {
6250
6252
  // A Stripe-side quantity push failed; the local quantity was NOT
6251
6253
  // changed, so the customer sees the unchanged value and can retry.
6252
6254
  processor: "We couldn't update your subscription with the payment processor. Nothing changed — please try again.",
6255
+ // A plan change already scheduled — the customer must cancel the
6256
+ // pending one before queuing another.
6257
+ pending_exists: "You already have a plan change scheduled. Cancel it before choosing another.",
6258
+ // The requested plan can't be switched to (same plan / different
6259
+ // currency / no longer offered).
6260
+ plan: "That plan isn't available to switch to. Pick another.",
6253
6261
  };
6254
6262
  function _subscriptionErrorCopy(code) {
6255
6263
  if (code == null) return null;
@@ -6279,6 +6287,12 @@ function renderAccountSubscriptions(opts) {
6279
6287
  // frequency control suppressed; a local-only controls instance keeps
6280
6288
  // offering it even on a Stripe-shaped row.
6281
6289
  var stripeAware = opts.stripe_backed === true;
6290
+ // When the plan-change surface is wired, each active subscription gains a
6291
+ // "Change plan" link, and any open (proposed/pending) change renders a
6292
+ // banner with a cancel control. `opts.pending_changes` maps subscription
6293
+ // id → the open change row (joined to its `to_plan`).
6294
+ var canChange = opts.can_change === true;
6295
+ var pendingChanges = opts.pending_changes || {};
6282
6296
  var rowsHtml = "";
6283
6297
  for (var i = 0; i < subs.length; i += 1) {
6284
6298
  var s = subs[i];
@@ -6332,6 +6346,38 @@ function renderAccountSubscriptions(opts) {
6332
6346
  cancelControl =
6333
6347
  "<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
6334
6348
  }
6349
+ // Change-plan link — offered only on a live (self-manageable, non-
6350
+ // terminal) subscription with an active billing period, mirroring the
6351
+ // change route's own preconditions. The proration preview + timing
6352
+ // choice lives on the dedicated /change page.
6353
+ var changeControl = "";
6354
+ if (canChange && _subscriptionSelfManageable(s) && _subscriptionControlState(s) === "active" &&
6355
+ s.current_period_start != null && s.current_period_end != null) {
6356
+ changeControl =
6357
+ "<a class=\"btn-ghost btn-ghost--sm subscription-card__change-link\" href=\"/account/subscriptions/" + esc(s.id) + "/change\">Change plan</a>";
6358
+ }
6359
+ // Pending plan-change banner — when an open change is scheduled for this
6360
+ // row, show the target plan + timing and a cancel control. The cancel
6361
+ // posts to the /change/cancel route; the change page also surfaces it.
6362
+ var pendingBanner = "";
6363
+ var pcRow = Object.prototype.hasOwnProperty.call(pendingChanges, s.id) ? pendingChanges[s.id] : null;
6364
+ if (pcRow) {
6365
+ var pcTo = pcRow.to_plan ? _planPriceCadence(pcRow.to_plan) : "a new plan";
6366
+ var pcWhen = pcRow.change_kind === "next_billing_cycle"
6367
+ ? "at your next billing date"
6368
+ : "shortly";
6369
+ var pcEff = pcRow.effective_at
6370
+ ? new Date(Number(pcRow.effective_at)).toISOString().slice(0, 10)
6371
+ : "";
6372
+ pendingBanner =
6373
+ "<div class=\"subscription-card__pending-change\" role=\"status\">" +
6374
+ "<span class=\"subscription-card__pending-copy\">Plan change to " + pcTo + " scheduled " + esc(pcWhen) +
6375
+ (pcEff ? " (<time datetime=\"" + esc(pcEff) + "\">" + esc(pcEff) + "</time>)" : "") + ".</span>" +
6376
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change/cancel\">" +
6377
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel change</button>" +
6378
+ "</form>" +
6379
+ "</div>";
6380
+ }
6335
6381
  // Self-manage controls — state-gated, mirroring the primitive's FSM
6336
6382
  // (active ⇒ pause / skip / change-qty / change-freq; paused ⇒ resume;
6337
6383
  // cancelled-within-grace ⇒ reactivate). Pause is confirm-gated like
@@ -6408,7 +6454,9 @@ function renderAccountSubscriptions(opts) {
6408
6454
  "</div>" +
6409
6455
  (renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
6410
6456
  (stateNote ? "<p class=\"subscription-card__state\">" + stateNote + "</p>" : "") +
6457
+ pendingBanner +
6411
6458
  manageControls +
6459
+ changeControl +
6412
6460
  cancelControl +
6413
6461
  "</li>";
6414
6462
  }
@@ -6639,6 +6687,162 @@ function renderSubscriptionPauseConfirm(opts) {
6639
6687
  });
6640
6688
  }
6641
6689
 
6690
+ // Render a plan's "price + cadence" one-liner (e.g. "$24.99 per month").
6691
+ // Returns an already-escaped string. The price is formatted only when the
6692
+ // amount + currency are well-formed (a validated plan row always is); a
6693
+ // malformed row degrades to the cadence-only phrase by an EXPLICIT check
6694
+ // rather than by swallowing a thrown pricing.format error — so bad data
6695
+ // degrades visibly-by-design and never hides a defect behind a catch.
6696
+ function _planPriceCadence(plan) {
6697
+ var esc = b.template.escapeHtml;
6698
+ if (!plan) return "Plan unavailable";
6699
+ var ccy = String(plan.currency || "").toUpperCase();
6700
+ var amount = Number(plan.amount_minor);
6701
+ var every = Number(plan.interval_count) > 1
6702
+ ? "every " + Number(plan.interval_count) + " " + esc(String(plan.interval)) + "s"
6703
+ : "per " + esc(String(plan.interval));
6704
+ // pricing.format throws on a non-integer/negative amount or a non-ISO-4217
6705
+ // currency; gate on exactly those preconditions so it is only ever called
6706
+ // with input it accepts (no try/catch needed).
6707
+ if (Number.isInteger(amount) && amount >= 0 && /^[A-Z]{3}$/.test(ccy)) {
6708
+ return esc(pricing.format(amount, ccy)) + " " + every;
6709
+ }
6710
+ return every;
6711
+ }
6712
+
6713
+ // Customer-facing plan-change page. `opts.subscription` carries the
6714
+ // resolved current `plan`; `opts.candidates` is an array of
6715
+ // { plan, preview } where preview is the proposeChange result for the
6716
+ // IMMEDIATE timing (proration_credit_minor + first_charge_minor +
6717
+ // currency). `opts.pending` is the open change to offer a cancel for, or
6718
+ // null. Confirm-gated: a single form, one radio per candidate plan, an
6719
+ // immediate-vs-next-cycle timing choice, and a submit. CSP forbids
6720
+ // inline script, so the proration math is server-rendered as static copy
6721
+ // — there's no client recompute. The CSRF field is spliced into the POST
6722
+ // form by `_injectCsrfFields` at send time. Everything dynamic is esc()'d.
6723
+ function renderSubscriptionPlanChange(opts) {
6724
+ var esc = b.template.escapeHtml;
6725
+ var s = opts.subscription || {};
6726
+ var candidates = opts.candidates || [];
6727
+ var pending = opts.pending || null;
6728
+ var currentSummary = _planPriceCadence(s.plan || null);
6729
+
6730
+ var noticeHtml = opts.error
6731
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.error)) + "</p>"
6732
+ : "";
6733
+
6734
+ // A pending change blocks queuing a new one (the primitive refuses a
6735
+ // second). Render the standing change + a cancel control instead of the
6736
+ // candidate picker.
6737
+ var pendingHtml = "";
6738
+ if (pending) {
6739
+ var toSummary = _planPriceCadence(pending.to_plan || null);
6740
+ var kindCopy = pending.change_kind === "next_billing_cycle"
6741
+ ? "at your next billing date"
6742
+ : "immediately";
6743
+ var effDate = pending.effective_at
6744
+ ? new Date(Number(pending.effective_at)).toISOString().slice(0, 10)
6745
+ : "";
6746
+ pendingHtml =
6747
+ "<div class=\"plan-change-pending\">" +
6748
+ "<p class=\"plan-change-pending__lede\">A change to " + toSummary + " is scheduled " + esc(kindCopy) +
6749
+ (effDate ? " (<time datetime=\"" + esc(effDate) + "\">" + esc(effDate) + "</time>)" : "") + ".</p>" +
6750
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change/cancel\">" +
6751
+ "<button type=\"submit\" class=\"btn-ghost\">Cancel scheduled change</button>" +
6752
+ "</form>" +
6753
+ "</div>";
6754
+ }
6755
+
6756
+ // Candidate picker — only when there's no pending change to clear first.
6757
+ var pickerHtml = "";
6758
+ if (!pending) {
6759
+ if (!candidates.length) {
6760
+ pickerHtml = "<p class=\"plan-change__empty\">There are no other plans to switch to right now.</p>";
6761
+ } else {
6762
+ var optionsHtml = "";
6763
+ for (var i = 0; i < candidates.length; i += 1) {
6764
+ var c = candidates[i];
6765
+ var plan = c.plan;
6766
+ var preview = c.preview || {};
6767
+ var prevCcy = String(preview.currency || (plan && plan.currency) || "usd").toUpperCase();
6768
+ var creditMinor = Number(preview.proration_credit_minor) || 0;
6769
+ var chargeMinor = Number(preview.first_charge_minor) || 0;
6770
+ // Net the proration into the single number the customer cares about:
6771
+ // an upgrade charges (charge − credit) now; a downgrade leaves them
6772
+ // (credit − charge) in store credit. Exactly one is non-zero, both
6773
+ // floored at 0. Mirrors the primitive's executeChange split.
6774
+ var netCharge = chargeMinor - creditMinor > 0 ? chargeMinor - creditMinor : 0;
6775
+ var netCredit = creditMinor - chargeMinor > 0 ? creditMinor - chargeMinor : 0;
6776
+ // netCharge/netCredit are non-negative integers by construction; the
6777
+ // only value that could make pricing.format throw is a malformed
6778
+ // currency, so gate the formatted copy on a valid ISO-4217 code by an
6779
+ // EXPLICIT check rather than catching a thrown format error. A bad
6780
+ // currency degrades to the amount-free phrase, visibly-by-design.
6781
+ var validCcy = /^[A-Z]{3}$/.test(prevCcy);
6782
+ var prorationCopy;
6783
+ if (validCcy && netCharge > 0) {
6784
+ prorationCopy = "Switch now and you'll be charged " + esc(pricing.format(netCharge, prevCcy)) + " for the rest of this period.";
6785
+ } else if (validCcy && netCredit > 0) {
6786
+ prorationCopy = "Switch now and you'll get " + esc(pricing.format(netCredit, prevCcy)) + " in store credit for the unused part of this period.";
6787
+ } else if (validCcy) {
6788
+ prorationCopy = "Switch now at no extra charge for the rest of this period.";
6789
+ } else {
6790
+ prorationCopy = "Switch now — proration applies for the rest of this period.";
6791
+ }
6792
+ optionsHtml +=
6793
+ "<li class=\"plan-change-option\">" +
6794
+ "<label class=\"plan-change-option__label\">" +
6795
+ "<input type=\"radio\" name=\"new_plan_id\" value=\"" + esc(String(plan.id)) + "\"" + (i === 0 ? " checked" : "") + " required>" +
6796
+ "<span class=\"plan-change-option__plan\">" + _planPriceCadence(plan) + "</span>" +
6797
+ "<span class=\"plan-change-option__proration\">" + prorationCopy + "</span>" +
6798
+ "</label>" +
6799
+ "</li>";
6800
+ }
6801
+ pickerHtml =
6802
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/change\">" +
6803
+ "<fieldset class=\"plan-change__plans\">" +
6804
+ "<legend class=\"plan-change__legend\">Choose a new plan</legend>" +
6805
+ "<ul class=\"plan-change-options\">" + optionsHtml + "</ul>" +
6806
+ "</fieldset>" +
6807
+ "<fieldset class=\"plan-change__timing\">" +
6808
+ "<legend class=\"plan-change__legend\">When should it take effect?</legend>" +
6809
+ "<label class=\"plan-change-timing__option\">" +
6810
+ "<input type=\"radio\" name=\"timing\" value=\"immediate\" checked>" +
6811
+ "<span>Now — with proration for the rest of this period</span>" +
6812
+ "</label>" +
6813
+ "<label class=\"plan-change-timing__option\">" +
6814
+ "<input type=\"radio\" name=\"timing\" value=\"next_cycle\">" +
6815
+ "<span>At my next billing date — no proration, the new plan starts clean</span>" +
6816
+ "</label>" +
6817
+ "</fieldset>" +
6818
+ "<button type=\"submit\" class=\"btn-primary\">Change plan</button>" +
6819
+ "</form>";
6820
+ }
6821
+ }
6822
+
6823
+ var body =
6824
+ "<section class=\"account-confirm plan-change\">" +
6825
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
6826
+ "<li><a href=\"/account\">Account</a></li>" +
6827
+ "<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
6828
+ "<li aria-current=\"page\">Change plan</li>" +
6829
+ "</ol></nav>" +
6830
+ "<h1 class=\"account-confirm__title\">Change your plan</h1>" +
6831
+ "<p class=\"plan-change__current\">You're currently on " + currentSummary + ".</p>" +
6832
+ noticeHtml +
6833
+ pendingHtml +
6834
+ pickerHtml +
6835
+ "<a class=\"btn-ghost\" href=\"/account/subscriptions\">Back to subscriptions</a>" +
6836
+ "</section>";
6837
+ return _wrap({
6838
+ title: "Change plan",
6839
+ shop_name: opts.shop_name || "blamejs.shop",
6840
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
6841
+ theme_css: opts.theme_css,
6842
+ body: body,
6843
+ });
6844
+ }
6845
+
6642
6846
  // Product-level "Save to wishlist" control + social-proof count.
6643
6847
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
6644
6848
  // so both paths emit identical markup. Action-only label — the toggle
@@ -18865,6 +19069,14 @@ function mount(router, deps) {
18865
19069
  // state — so they're available even on a deploy with no payment
18866
19070
  // handle. The list above stays a read-only view when this is absent.
18867
19071
  var subControls = deps.subscriptionControls || null;
19072
+ // Proration-aware plan upgrade / downgrade. Mounts the
19073
+ // /account/subscriptions/:id/change confirm-gated surface (preview the
19074
+ // proration credit / first charge per candidate plan + pick immediate
19075
+ // vs next-cycle timing) plus the cancel-pending POST. Wired only when
19076
+ // server.js composed it (needs subscriptions + store credit — the
19077
+ // vehicle a mid-cycle downgrade's owed credit pays into). Absent it,
19078
+ // the list stays the lifecycle-only view above.
19079
+ var planChanges = deps.planChanges || null;
18868
19080
 
18869
19081
  async function _subsAuth(req, res) {
18870
19082
  var auth;
@@ -18939,6 +19151,33 @@ function mount(router, deps) {
18939
19151
  ? "Your subscription has been canceled."
18940
19152
  : _subscriptionOkCopy(okKind);
18941
19153
  var errKind = url ? url.searchParams.get("error") : null;
19154
+ // Pending plan changes — when the plan-change surface is wired, look
19155
+ // up the open (proposed/pending) change per subscription so the list
19156
+ // can show a "change scheduled" banner with a cancel control. The
19157
+ // banner is an enhancement to a critical page (the list must render),
19158
+ // so a read failure is OBSERVED via _log.error and the banners are
19159
+ // dropped for this render — degrade-with-observability, never a silent
19160
+ // swallow. plans.get returns null for a deleted target plan (handled
19161
+ // by the renderer); only a genuine failure reaches the catch.
19162
+ var pendingChanges = {};
19163
+ if (planChanges) {
19164
+ try {
19165
+ for (var pci = 0; pci < rows.length; pci += 1) {
19166
+ var pcSub = rows[pci];
19167
+ var pcRow = await planChanges.pendingChangeFor(pcSub.id);
19168
+ if (pcRow) {
19169
+ pcRow.to_plan = await subscriptions.plans.get(pcRow.to_plan_id);
19170
+ pendingChanges[pcSub.id] = pcRow;
19171
+ }
19172
+ }
19173
+ } catch (e) {
19174
+ _log.error("subscription list: pending plan-change lookup failed", {
19175
+ request_id: (req && req.requestId) || null,
19176
+ err: (e && e.message) || String(e),
19177
+ });
19178
+ pendingChanges = {};
19179
+ }
19180
+ }
18942
19181
  _send(res, 200, renderAccountSubscriptions({
18943
19182
  subscriptions: rows,
18944
19183
  can_cancel: subsCanCancel,
@@ -18948,6 +19187,10 @@ function mount(router, deps) {
18948
19187
  // handle with retrieve + update was wired) — otherwise the
18949
19188
  // controls are local-only and the cadence is still editable.
18950
19189
  stripe_backed: !!(subControls && subControls.stripeBacked),
19190
+ // Plan-change link + pending-change banner render only when the
19191
+ // surface is wired.
19192
+ can_change: !!planChanges,
19193
+ pending_changes: pendingChanges,
18951
19194
  notice: notice,
18952
19195
  error: _subscriptionErrorCopy(errKind),
18953
19196
  shop_name: shopName,
@@ -19172,6 +19415,183 @@ function mount(router, deps) {
19172
19415
  return res.end ? res.end() : res.send("");
19173
19416
  });
19174
19417
  }
19418
+
19419
+ // Plan change — proration-aware self-serve upgrade / downgrade. Mounts
19420
+ // only when the plan-change primitive is wired (server.js composes it
19421
+ // from subscriptions + store credit). The GET renders a confirm-gated
19422
+ // page listing every candidate plan with its proration preview; the
19423
+ // POST executes the chosen change (immediate or next-cycle); a third
19424
+ // POST cancels a still-pending change. Every route owns its
19425
+ // subscription through the same `_ownedSubscription` IDOR check the
19426
+ // cancel flow uses (a forged/foreign id 404s before any read), and
19427
+ // every primitive refusal maps to a fixed ?error code rather than a
19428
+ // 500.
19429
+ if (planChanges) {
19430
+ function _pcRedirect(res, suffix) {
19431
+ res.status(303);
19432
+ res.setHeader && res.setHeader("location", "/account/subscriptions" + suffix);
19433
+ return res.end ? res.end() : res.send("");
19434
+ }
19435
+
19436
+ // Translate a planChanges rejection into a fixed ?error code. A
19437
+ // pending-change collision, an unknown subscription/plan, and a
19438
+ // validation TypeError (cross-currency / same-plan / archived plan)
19439
+ // each map to a stable code; a Stripe-backed change whose target
19440
+ // plan has no Stripe price (PLAN_CHANGE_STRIPE_PRICE_MISSING) is a
19441
+ // misconfiguration the customer can't act on but must not 500;
19442
+ // a transient Stripe push failure (PLAN_CHANGE_STRIPE_PUSH_FAILED /
19443
+ // _NO_ITEM) leaves the local plan untouched and surfaces a "nothing
19444
+ // changed, retry" notice rather than a 500. Anything else rethrows
19445
+ // (a real 500, e.g. an unmigrated table on a misconfigured deploy).
19446
+ function _planChangeError(e) {
19447
+ if (e && e.code === "PLAN_CHANGE_REFUSED") return "pending_exists";
19448
+ if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
19449
+ if (e && e.code === "PLAN_NOT_FOUND") return "state";
19450
+ if (e && e.code === "NO_PENDING_CHANGE") return "state";
19451
+ if (e && e.code === "PLAN_CHANGE_STRIPE_PRICE_MISSING") return "plan";
19452
+ if (e && e.code === "PLAN_CHANGE_STRIPE_PUSH_FAILED") return "processor";
19453
+ if (e && e.code === "PLAN_CHANGE_STRIPE_NO_ITEM") return "processor";
19454
+ if (e instanceof TypeError) return "plan";
19455
+ return null;
19456
+ }
19457
+
19458
+ // Resolve every plan a subscription can switch TO: every active plan
19459
+ // sharing the current plan's currency, minus the current plan
19460
+ // itself. A cross-currency or archived plan is excluded here so the
19461
+ // confirm page never offers a candidate the primitive would refuse.
19462
+ // For each candidate, compute the immediate-timing proration preview
19463
+ // (credit + first charge) — the page shows both timings, but only
19464
+ // the immediate one prorates, so a single proposeChange per candidate
19465
+ // covers the preview. proposeChange persists nothing.
19466
+ async function _changeCandidates(sub) {
19467
+ // A read failure here is a real error, not a "no candidates" state —
19468
+ // let it propagate to the route's _routeError (logged + generic),
19469
+ // never mask it. The current-plan lookup returns null for a deleted
19470
+ // plan (a legitimate value handled below), and only throws on a real
19471
+ // DB failure (which also propagates).
19472
+ var allPlans = await subscriptions.plans.list({ active: true });
19473
+ var fromPlan = sub.plan_id != null ? await subscriptions.plans.get(sub.plan_id) : null;
19474
+ if (!fromPlan) return []; // no resolvable current plan → nothing to prorate a switch against
19475
+ var fromCcy = String(fromPlan.currency);
19476
+ var fromAmount = Number(fromPlan.amount_minor);
19477
+ var periodStart = Number(sub.current_period_start);
19478
+ var periodEnd = Number(sub.current_period_end);
19479
+ var nowMs = Date.now();
19480
+ var out = [];
19481
+ for (var i = 0; i < allPlans.length; i += 1) {
19482
+ var p = allPlans[i];
19483
+ if (p.id === sub.plan_id) continue; // never the current plan
19484
+ if (!p.active) continue; // archived → executeChange refuses
19485
+ if (String(p.currency) !== fromCcy) continue; // cross-currency → executeChange refuses
19486
+ // Immediate-timing proration preview via the primitive's exported
19487
+ // PURE math — no DB round-trip and no throw for valid integers, so
19488
+ // there is no error to swallow. The actual switch re-validates the
19489
+ // full shape through executeChange on POST.
19490
+ var pr = planChanges.prorate(fromAmount, Number(p.amount_minor), periodStart, periodEnd, nowMs);
19491
+ out.push({
19492
+ plan: p,
19493
+ preview: {
19494
+ proration_credit_minor: pr.proration_credit_minor,
19495
+ first_charge_minor: pr.first_charge_minor,
19496
+ currency: fromCcy,
19497
+ },
19498
+ });
19499
+ }
19500
+ return out;
19501
+ }
19502
+
19503
+ router.get("/account/subscriptions/:id/change", async function (req, res) {
19504
+ var auth = await _subsAuth(req, res); if (!auth) return;
19505
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19506
+ // Only an active, self-manageable subscription can change plans — a
19507
+ // terminal / paused / cancelled row (even one that still carries
19508
+ // period columns) is refused here, matching the display gate that
19509
+ // decides whether to show the "Change plan" link.
19510
+ if (!_subscriptionSelfManageable(sub) || _subscriptionControlState(sub) !== "active") {
19511
+ return _pcRedirect(res, "?error=state");
19512
+ }
19513
+ // A subscription with no live billing period can't be prorated;
19514
+ // bounce to the list rather than render an empty change page.
19515
+ if (sub.current_period_start == null || sub.current_period_end == null) {
19516
+ return _pcRedirect(res, "?error=state");
19517
+ }
19518
+ try {
19519
+ // A read failure on any of these is a real error — route it
19520
+ // through _routeError (logs server-side + returns a generic,
19521
+ // leak-free message) rather than masking it with a per-read
19522
+ // catch. plans.get returns null for a deleted plan (a value the
19523
+ // renderer handles); only a genuine failure throws here.
19524
+ if (sub.plan_id != null) sub.plan = await subscriptions.plans.get(sub.plan_id);
19525
+ var candidates = await _changeCandidates(sub);
19526
+ var pending = await planChanges.pendingChangeFor(sub.id);
19527
+ if (pending) pending.to_plan = await subscriptions.plans.get(pending.to_plan_id);
19528
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
19529
+ var errKind = url ? url.searchParams.get("error") : null;
19530
+ var cartCount = await _cartCountForReq(req);
19531
+ _send(res, 200, renderSubscriptionPlanChange({
19532
+ subscription: sub,
19533
+ candidates: candidates,
19534
+ pending: pending,
19535
+ error: _subscriptionErrorCopy(errKind),
19536
+ shop_name: shopName,
19537
+ cart_count: cartCount,
19538
+ }));
19539
+ } catch (e) {
19540
+ return _routeError(req, res, e);
19541
+ }
19542
+ });
19543
+
19544
+ router.post("/account/subscriptions/:id/change", async function (req, res) {
19545
+ var auth = await _subsAuth(req, res); if (!auth) return;
19546
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19547
+ // Reject a plan change on a non-active subscription (terminal /
19548
+ // paused / cancelled) before any write — the same gate the display
19549
+ // and GET enforce, so a forged POST can't re-plan a wound-down row.
19550
+ if (!_subscriptionSelfManageable(sub) || _subscriptionControlState(sub) !== "active") {
19551
+ return _pcRedirect(res, "?error=state");
19552
+ }
19553
+ var body = req.body || {};
19554
+ var newPlanId = String(body.new_plan_id || "");
19555
+ // Backend validates: an empty / missing plan id is a client error
19556
+ // → bounce with the plan code rather than handing garbage to the
19557
+ // primitive.
19558
+ if (!newPlanId) return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=plan");
19559
+ var timing = String(body.timing || "immediate");
19560
+ if (timing !== "immediate" && timing !== "next_cycle") {
19561
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=state");
19562
+ }
19563
+ // next_cycle defers the change to the period boundary: the
19564
+ // primitive derives `next_billing_cycle` (and zero proration) when
19565
+ // change_at >= current_period_end. Immediate omits change_at, so
19566
+ // the primitive applies it at Date.now() with full proration.
19567
+ var changeArgs = { subscription_id: sub.id, new_plan_id: newPlanId };
19568
+ if (timing === "next_cycle") {
19569
+ if (sub.current_period_end == null) {
19570
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=state");
19571
+ }
19572
+ changeArgs.change_at = Number(sub.current_period_end);
19573
+ }
19574
+ try {
19575
+ await planChanges.executeChange(changeArgs);
19576
+ } catch (e) {
19577
+ var code = _planChangeError(e); if (code == null) throw e;
19578
+ return _pcRedirect(res, "/" + encodeURIComponent(sub.id) + "/change?error=" + code);
19579
+ }
19580
+ return _pcRedirect(res, "?ok=plan_changed");
19581
+ });
19582
+
19583
+ router.post("/account/subscriptions/:id/change/cancel", async function (req, res) {
19584
+ var auth = await _subsAuth(req, res); if (!auth) return;
19585
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
19586
+ try {
19587
+ await planChanges.cancelPendingChange({ subscription_id: sub.id });
19588
+ } catch (e) {
19589
+ var code = _planChangeError(e); if (code == null) throw e;
19590
+ return _pcRedirect(res, "?error=" + code);
19591
+ }
19592
+ return _pcRedirect(res, "?ok=change_canceled");
19593
+ });
19594
+ }
19175
19595
  }
19176
19596
 
19177
19597
  // Pre-order reservations — the PDP reserve POST + the customer's
@@ -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.15.37",
7
- "tag": "v0.15.37",
6
+ "version": "0.15.38",
7
+ "tag": "v0.15.38",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -860,6 +860,7 @@
860
860
  "release-notes/v0.15.35.json": "lib/vendor/blamejs/release-notes/v0.15.35.json",
861
861
  "release-notes/v0.15.36.json": "lib/vendor/blamejs/release-notes/v0.15.36.json",
862
862
  "release-notes/v0.15.37.json": "lib/vendor/blamejs/release-notes/v0.15.37.json",
863
+ "release-notes/v0.15.38.json": "lib/vendor/blamejs/release-notes/v0.15.38.json",
863
864
  "release-notes/v0.15.4.json": "lib/vendor/blamejs/release-notes/v0.15.4.json",
864
865
  "release-notes/v0.15.5.json": "lib/vendor/blamejs/release-notes/v0.15.5.json",
865
866
  "release-notes/v0.15.6.json": "lib/vendor/blamejs/release-notes/v0.15.6.json",
@@ -1568,7 +1569,7 @@
1568
1569
  ".npmrc": "sha256:66f104e7d07c496d2d0409988225e8c0e4ceb8d247dbcac3be75b2128d20ce66",
1569
1570
  ".pinact.yaml": "sha256:0213ffda55961dc49b64c0a5dfa3c0567419633b1499d57eaf7c8d842d7da6c7",
1570
1571
  "ARCHITECTURE.md": "sha256:9b1c8d2b1b7a41838eb348b0a008e4b4369718fd72bfe2974b37155f7536d35b",
1571
- "CHANGELOG.md": "sha256:e14e4572e0a057f9633365e55eb176592f766006a4392009a25c9060d9b17cc0",
1572
+ "CHANGELOG.md": "sha256:d46b3114be4ce1b16f02a49559affdf7dcf61567153b2a93f1be3d01e90de015",
1572
1573
  "CODE_OF_CONDUCT.md": "sha256:148a281960fff7c2fe6554dab66da572c72245ddeb00b0d14811558397bff386",
1573
1574
  "CONTRIBUTING.md": "sha256:bb4dbdbc8598da31dbce653a8ed322e08ff46560173f2eb67a4d684653948332",
1574
1575
  "GOVERNANCE.md": "sha256:906df6afb1f552b27b9acb50f7f96c47b917a2f1021cd4e987dbf4ee0e0a821b",
@@ -1578,7 +1579,7 @@
1578
1579
  "NOTICE": "sha256:f487fa47a11aca0f89e2615cdd3c713e9842abf7a30d8d328eeeae1c864aa774",
1579
1580
  "README.md": "sha256:3ddcc197b003da0b02db8bdd1aef1e943c94f7eab613c633d6a45bb11d0a80e9",
1580
1581
  "SECURITY.md": "sha256:77d8c2bcc04b425a08ef30e51204cebddbd48954b028a259c7a8412dbfeab40a",
1581
- "api-snapshot.json": "sha256:8665f75a6befdc47255199601827a9642aa9f1662f271c35325068482edb11ad",
1582
+ "api-snapshot.json": "sha256:179287667873f052b36735f3473fbce51aa51c9ed26836bbbdfa854e096a6c0c",
1582
1583
  "assets/BlameJS_Logo.png": "sha256:3c65699753c771b48ef9ac7f45bb40815ec19a23afcdd0cd30ef4601bbbe293e",
1583
1584
  "assets/BlameJS_Logo.svg": "sha256:dda44f3fb1343d5de9db6b1fcdb75fc649c57e7a99a8e8239fcf852e3841e1a8",
1584
1585
  "bench/README.md": "sha256:74202f2507fd840bfc1ac6c681975d9273cf36cca6e0f72655f138337304033c",
@@ -1958,7 +1959,7 @@
1958
1959
  "lib/flag-cache.js": "sha256:4333ce5105e2a2970b7277fa77f6664d1c6d2e3c6e7c3fa6094c228d305df4dc",
1959
1960
  "lib/flag-evaluation-context.js": "sha256:fa8846a9a97a9d773dc08dd0da664d1d6d3fead4e12dc9982b33fa45b59d47a8",
1960
1961
  "lib/flag-providers.js": "sha256:d85ff62c2d599634c3d2dc9d4e4a8cfcecec8178ce63a2800438434cc365f84a",
1961
- "lib/flag-targeting.js": "sha256:c2b5096e04dcaa98435c66cfa6b1aae02d0da708ca7e96f810627ef38e669309",
1962
+ "lib/flag-targeting.js": "sha256:70c1d14b672600322a472a6dca6e149cb78428f7f96d7e9733bea5e25ec42ac4",
1962
1963
  "lib/flag.js": "sha256:3eab2c7e2f86fdf0a099d4ba287ec69486ee985c5496077ce79167979f6fccaf",
1963
1964
  "lib/forms.js": "sha256:e8a90aee2497da53d62fed67a674aefda3b4665c75dcee284334ec762753b692",
1964
1965
  "lib/framework-error.js": "sha256:6b707ad3cb0c1f180671165381ef228fa9ceb7187bd7202ff6eec0af03304a28",
@@ -2011,7 +2012,7 @@
2011
2012
  "lib/guard-pdf.js": "sha256:7a739adea96a1d6624858232e9b5991071aee5a25253914bbcc1f51b70e06710",
2012
2013
  "lib/guard-pop3-command.js": "sha256:f4c62cf6e5c4e4ccdaf7f39f8a738b6febeedd2b2ce65abf7d365e7a19fdfa95",
2013
2014
  "lib/guard-posture-chain.js": "sha256:acf47bba9c1e5ad743965a509ff97c067f6fa157fbd08fc57b63965adcc52411",
2014
- "lib/guard-regex.js": "sha256:7cb10f144f71d76ca0d9606b9153a834cfc9caa0cb687b51780cd754cc529690",
2015
+ "lib/guard-regex.js": "sha256:ef6100d7b68c017166454301d787f7bffc988e97bcc1b0b09981c045b55b0d16",
2015
2016
  "lib/guard-saga-config.js": "sha256:b7e0031f95adea39cc4ac7a9c2487d62b49b833c058ea90e5962d6b0bcea82f3",
2016
2017
  "lib/guard-shell.js": "sha256:2f7948748cb4bac489e973573f4a7b728306eda4d9e308e5c9a40e23b17dcfb3",
2017
2018
  "lib/guard-smtp-command.js": "sha256:a4faf89c1582901a20976950191da8f7ecdeae1a2c47a429147f09f464ece146",
@@ -2108,7 +2109,7 @@
2108
2109
  "lib/markup-escape.js": "sha256:a76e8c77f30ae456caa53c33afeb2dd0b27f7b8799ac857beac86ea038f17d9a",
2109
2110
  "lib/markup-tokenizer.js": "sha256:8aa91d3e173c8d3d4df4dac00e892a359863be7295c5b1846cf322bc7bfc9510",
2110
2111
  "lib/mcp-tool-registry.js": "sha256:df3480e6a0d29a9734bdb19d322f83bff5ebfdd756620f947cf593878dea8776",
2111
- "lib/mcp.js": "sha256:c65af74fdb3cf30436699aba5051c4d6e8cb972887cbaaecc46250d557599953",
2112
+ "lib/mcp.js": "sha256:b7717a2e3928429c745e049512fa1c91f9ea44eb35c0f4324e2083d2f3fdab28",
2112
2113
  "lib/mdoc.js": "sha256:bc31d38d6fdfac8db8f040dfa4d33ab40405f56340bb4ec978f3ba98e93f6433",
2113
2114
  "lib/metrics.js": "sha256:55d445b738b2cfc8ceb1bfa7a1e69b306497e8065ea86d61e4f4087873acce1b",
2114
2115
  "lib/middleware/age-gate.js": "sha256:4d4967beef100e2e58f1565de770e31fb8dd50dab2f04fa8251972fea5607826",
@@ -2349,7 +2350,7 @@
2349
2350
  "oss-fuzz/projects/blamejs/README.md": "sha256:ae13b7bb79ed8d69b1b3276e5562807a0349fb6e6b7d11cf1f683aad1eafdb4b",
2350
2351
  "oss-fuzz/projects/blamejs/build.sh": "sha256:0ced1cf21782c97be7f8d74faf5e27a308b60b2f858836fb5ca3b8c4e939a8f7",
2351
2352
  "oss-fuzz/projects/blamejs/project.yaml": "sha256:59f2cb83aa622325a175b77416fe155be15b70a9c798bd1a78bba05763b1b03d",
2352
- "package.json": "sha256:ffb1cef6e0b19b328852007de2c92144fdb0a08f4627a4b3bf048eeb3857bee4",
2353
+ "package.json": "sha256:a1c6ea35b2eb0ec3bd7bc2dfb809260f5bad56202962bed60b939b0d1ad01d07",
2353
2354
  "release-notes/v0.0.x.json": "sha256:7a49819f30068ee119000cad7010194882bb8bfaa12acbdab4dfc066efb7982f",
2354
2355
  "release-notes/v0.1.x.json": "sha256:6742a8c17f947c5cb76f69dead7eea86b942d80621d914b774ba5488e09937e5",
2355
2356
  "release-notes/v0.10.x.json": "sha256:fe498045daf88337bd3d987e5964aa42c99a50e1685b6f09e51f698b8687726f",
@@ -2389,6 +2390,7 @@
2389
2390
  "release-notes/v0.15.35.json": "sha256:e317b2252734aace7a0f734e3ab40c3a0c4ac208d678c0e9834be34eb424d15b",
2390
2391
  "release-notes/v0.15.36.json": "sha256:392c15724efc02fad5825aaf93b3f5dcef90a0cb9909d61f86e777f3061045ec",
2391
2392
  "release-notes/v0.15.37.json": "sha256:b696debed024bed09c59eaeb467d6dde93d7f30ad33f46af8a37d07534df09d8",
2393
+ "release-notes/v0.15.38.json": "sha256:8a2e21eec92c95173689cdcf6055b9d3375ba0f765692c7cdb1e7313a5010ddc",
2392
2394
  "release-notes/v0.15.4.json": "sha256:6ac7fa0ef1728c27e71b2050d1b07a810f9b4b1440ccddbf28ad56e2f54d8585",
2393
2395
  "release-notes/v0.15.5.json": "sha256:cca1d0edd5d6fc41b512d19d98be224b990dcab41478622c11962f0fcb1bb09a",
2394
2396
  "release-notes/v0.15.6.json": "sha256:0e3b9e5e43b70b61dd258c3003d1b8729cd3c26c62a34dedcca81bbec5d31077",
@@ -2739,7 +2741,7 @@
2739
2741
  "test/layer-0-primitives/fido-mds3.test.js": "sha256:91893e57258f91386ea3baedf8727dac08b5c75725a74b74ecdfbc5d99bb4adc",
2740
2742
  "test/layer-0-primitives/file-type.test.js": "sha256:ba6bb725857cbf7b165f4391e3aa126c9fc4286c5afb92d1964ff1d0dfd2b57c",
2741
2743
  "test/layer-0-primitives/file-upload-content-safety-skip-audit.test.js": "sha256:ad13f7f84a33899b4f4e66b74c896f606e070bfe5df769b0acd109cfb7ab38c7",
2742
- "test/layer-0-primitives/flag.test.js": "sha256:37f17c037220ef60bb0afb79cbddd359a9b5784ed6ce8efd3dc04063fbc09176",
2744
+ "test/layer-0-primitives/flag.test.js": "sha256:e834ee3ae6469ada26eb3783dbba943c8ebce6c34787d8e1e8f9ff4dd07a5176",
2743
2745
  "test/layer-0-primitives/forensic-snapshot.test.js": "sha256:629a8af1ccd0396e2b30f8e46d76fe19eab9f33c156c0db78e3e76649e21030c",
2744
2746
  "test/layer-0-primitives/fsm.test.js": "sha256:7037831083426478ed4efd7d1c17d4b96c3609816a41819b5c9da71d2b6187e8",
2745
2747
  "test/layer-0-primitives/gate-contract-content-gate.test.js": "sha256:d9f8b94231efb0e02a94b7934bf58a3f8e343829ce11346b7302e69e13efd551",
@@ -2866,7 +2868,7 @@
2866
2868
  "test/layer-0-primitives/mail-unsubscribe.test.js": "sha256:ffe070b31ae59b1f3cb1582b56f494501dc97a493e89111573a8383fabf1a269",
2867
2869
  "test/layer-0-primitives/mail.test.js": "sha256:ef0dafe19c60852542f7cc523dfb0c92347261c43d706eeed58db9934d9f5c50",
2868
2870
  "test/layer-0-primitives/mcp-tool-registry.test.js": "sha256:4d9a9a367489d93afcc8e0bca3442e0aefc683e40ddd29e6e2cddbe3a3b8732f",
2869
- "test/layer-0-primitives/mcp.test.js": "sha256:896a9b55fd9d21a1ec2b0d0753b5796eb045911ad28992ba17338285406ac2f9",
2871
+ "test/layer-0-primitives/mcp.test.js": "sha256:31a29bee340edac7f305e8dc18b44134140dc058c5bdc1dc137f1f18886ad2af",
2870
2872
  "test/layer-0-primitives/mdoc.test.js": "sha256:2fe1086192385e71f858554d676b299b86594d34226e1d774ffefc97b5fa03a4",
2871
2873
  "test/layer-0-primitives/metrics-shadow-registry.test.js": "sha256:a91a9efdb4aa28caed846b969d7c1e5ebfc57067afad85800daa430942d02725",
2872
2874
  "test/layer-0-primitives/metrics-snapshot.test.js": "sha256:aa405be22acb6680b062d1de1aa7ce00029e414218a0e4e521e10a14b2254851",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.38 (2026-06-27) — **Regex patterns supplied in feature-flag targeting rules and MCP tool input schemas are now screened for catastrophic-backtracking (ReDoS) shapes before compilation, so a pattern matched against request data can't pin a CPU.** Two places compiled a caller-supplied regex pattern and `.test()`'d it against request-controlled input with only a length bound as the stated defense: a feature-flag targeting condition (`op: "regex"`) matched against runtime attribute values, and an MCP tool's input-schema `pattern` matched against tool-call arguments. A length bound is not a ReDoS defense — a catastrophic-backtracking pattern such as `(a+)+$` is six characters and pins a CPU on a crafted input. Both patterns now pass through `b.guardRegex` (strict profile) before compilation, which refuses nested-quantifier, alternation-with-quantifier, and quantifier-inside-lookaround shapes. A ReDoS-shaped flag pattern is refused when the rules are validated; a ReDoS-shaped MCP schema pattern fails tool-input validation. Patterns built from the framework's own static tables, operator-owned JSON Schema patterns, the Sieve glob translator (which cannot express nested quantifiers), and the I-Regexp translator (linear by dialect) are unchanged. **Security:** *Feature-flag regex targeting conditions are screened for ReDoS before compilation* — A flag targeting rule with `op: "regex"` compiled the operator-supplied pattern and `.test()`'d it against runtime attribute values, guarded only by a 200-character length cap. Length does not bound catastrophic backtracking, so a pattern like `(a+)+$` combined with an attacker-controlled attribute value could pin a CPU during flag evaluation. The pattern is now screened through b.guardRegex (strict) when the rules are validated, and a catastrophic-backtracking shape is refused with a clear error. · *MCP tool input-schema patterns are screened for ReDoS before matching request input* — b.mcp.validateToolInput compiled a tool author's input-schema `pattern` and matched it against tool-call argument values; the 4096-character input cap does not bound backtracking (a `(a+)+$` pattern blows up at roughly forty input characters). The schema pattern is now screened through b.guardRegex (strict) before compilation, so a ReDoS-shaped pattern in a registered tool's schema fails input validation instead of letting hostile arguments hang the validator. · *b.guardRegex now catches wrapped nested-quantifier patterns* — The nested-quantifier detector matched a quantified group whose body contained a quantifier, but its inner match was paren-blind, so wrapping the inner quantifier in an extra group — `((a)+)+`, `(([a-z]+)*)*`, `((a+))+` — slipped past it while remaining catastrophic. A structural scan now tracks group nesting and refuses an unbounded-quantified group whose body itself contains an unbounded quantifier at any depth, so the wrapped forms are rejected alongside the bare `(a+)+`. Bounded repeats (`{n}`, `{n,m}`) are unaffected. This strengthens every b.guardRegex caller, including the flag-targeting and MCP screening above.
12
+
11
13
  - v0.15.37 (2026-06-27) — **Several numeric options that silently accepted a non-finite value — disabling a clock-skew / freshness check or a resource cap — now reject it, so an `Infinity` skew or limit can no longer turn off the protection it configures.** A number of configuration options validated a numeric value with a bare `typeof === "number" && value >= 0` check, which accepts `Infinity`. Where the value is a clock-skew tolerance or a resource cap, an `Infinity` (or `NaN`) silently disabled the very check it tunes: a CWT / OCSP-staple / ARC clock-skew of `Infinity` made the expiry, freshness, and expiration comparisons unsatisfiable (an expired token / a replayed pre-revocation "good" response / an expired ARC seal would be accepted); a WebSocket-client `maxMessageBytes` / `maxFrameBytes` / `handshakeTimeoutMs` of `Infinity` disabled the inbound-OOM and stalled-handshake defenses; and inbox / flag-cache / audit-chain size and count caps of `Infinity` admitted unbounded data. These options now route through the finite-bounds validator: a present non-finite value is refused at the entry point (or falls back to the safe default on the result-returning paths). Options where an unbounded value is a deliberate intent — reconnect "retry indefinitely", inbox "retain indefinitely" — continue to accept `Infinity`. **Security:** *A non-finite clock-skew no longer disables CWT / OCSP / ARC time checks* — b.cwt.verify, the OCSP-staple freshness check in b.network.tls, and b.mail.arc.verify each took an operator clock-skew tolerance validated as `typeof === "number" && >= 0`, which accepts `Infinity`. Because each check is of the form `now > deadline + skew`, a skew of `Infinity` made it unsatisfiable and silently turned the check off: an expired CWT verified, a stale (post-nextUpdate) OCSP "good" response — the exact reply an attacker replays after the certificate is revoked — was accepted, and an expired ARC seal passed. A present clock-skew that is not a non-negative finite integer is now refused (b.cwt.verify throws cwt/bad-clock-skew; the OCSP and ARC paths fall back to their safe default). Regression tests assert an expired token / stale staple / expired ARC seal is still rejected when the skew is `Infinity`. · *WebSocket-client inbound caps can no longer be disabled by an Infinity value* — b.wsClient.connect validated maxMessageBytes, maxFrameBytes, and handshakeTimeoutMs (and the ping/pong keepalive intervals) with a bare numeric check that accepted `Infinity`, which disabled the inbound-message and frame size limits — the defenses against a malicious server sending an unbounded message — and the handshake timeout. A present non-finite value for these is now refused at connect time. The reconnect maxAttempts still accepts `Infinity` (a deliberate "reconnect indefinitely" intent). · *Inbox, flag-cache, and audit-chain caps reject a non-finite limit* — The inbox maxPayloadBytes / messageIdMaxLen / sourceMaxLen caps, the flag-cache ttlMs / maxEntries, and the audit-chain partition fan-out cap each accepted `Infinity`, disabling the cap (unbounded stored payloads, a never-expiring or unbounded cache, unbounded fan-out). These now require a positive finite integer — refused at create time, or clamped to the bounded default on the result-returning verify path. The inbox retentionDays still accepts `Infinity` (retain indefinitely).
12
14
 
13
15
  - v0.15.36 (2026-06-27) — **Internal test-suite hardening only — the published library's runtime behavior and public API are unchanged (source-comment marker text aside).** The codebase-patterns guard class that allows a bare comma/semicolon split on token-only RFC header grammars was re-verified from scratch and renamed to a descriptive token, with its old name recorded as retired. Each live marked site (RRULE, RFC 9421 component identifiers, TLS-RPT rua, SCIM attribute paths) was re-read and confirmed to split a grammar with no quoted-string members. Five marker comments that suppressed nothing were removed or turned into plain explanatory comments. No runtime code, public API, or wire format changed. **Detectors:** *Bare token-only header-split suppression class re-verified, renamed, and pruned of inert markers* — Each marked bare `.split(",")` / `.split(";")` on an RFC header value was re-read and confirmed to operate on a token-only grammar (no quoted-string members, so a quote-aware splitter is unnecessary): RFC 5545 RRULE, RFC 9421 signature component identifiers, RFC 8460 TLS-RPT rua, and RFC 7644 SCIM attribute paths. The guard class was renamed to a descriptive token and its old name added to the retired-token set. Five marker comments that the detector never actually evaluated (two sat on a date-normalizing `.replace`, three in header parsers the guard intentionally does not scan) were removed or converted to plain comments. This is test-suite tooling plus source-comment text; no shipped framework behavior changed.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.15.37",
4
- "createdAt": "2026-06-27T08:30:34.621Z",
3
+ "frameworkVersion": "0.15.38",
4
+ "createdAt": "2026-06-27T15:30:08.749Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",