@blamejs/blamejs-shop 0.3.18 → 0.3.20

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.20 (2026-05-30) — **Served media carries hardened headers, primary-image changes are product-scoped, and image-by-URL imports are size-capped.** Three defense-in-depth fixes on product media. Media assets served from storage now carry X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, and SVG files additionally carry a content policy that prevents script from running when the file is opened directly while still rendering normally inside a product image. Setting a product's primary image now verifies the image actually belongs to the product named in the request rather than acting on the image alone. And importing a product image by URL now refuses a response larger than the same 10 MiB limit the file-upload path enforces, instead of buffering an unbounded download. **Changed:** *Setting the primary image is product-scoped* — The endpoint that makes an image a product's primary (hero) now verifies the image belongs to the product in the request path and returns not-found otherwise, instead of acting on the image regardless of the product named. A request whose product and image don't match is now rejected. · *Vendored runtime updated to v0.14.9* — The vendored blamejs runtime is updated to v0.14.9, a documentation-path and source-comment fix with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Hardened response headers on served media* — Media served from storage now sets X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, so a mistyped or hostile upload can't be content-sniffed into something executable or embedded cross-origin. An SVG additionally carries a default-src 'none' sandbox content-security policy, so opening an SVG URL directly cannot run script (it still renders inside an img tag). This sits behind the existing SVG-upload sanitizer as a second layer. · *Importing an image by URL is size-capped* — Importing a product image from a URL now refuses a response larger than 10 MiB — the same limit a direct file upload uses — returning a clear error instead of buffering an unbounded download.
12
+
13
+ - v0.3.19 (2026-05-30) — **Edit announcement bars, automatic-discount terms, and subscription plans from the console.** Three admin entities could be created but only partly edited from the console — the rest of their fields were reachable only through the API, so changing them meant archive-and-recreate or a manual API call. Each now has a detail screen with the full edit form. An announcement bar can have its message, links, schedule, audience, and dismissibility changed in place. An automatic discount can have its actual terms changed — the amount, percentage, threshold, or buy-x-get-y values — not just its title, priority, and on/off state. A subscription plan can have its price, billing interval, trial length, active state, and variant edited. Create and archive behavior is unchanged. **Fixed:** *Announcement bars are editable in place* — A detail screen now lets you change an announcement's message, link, theme, audience, start/end schedule, and whether it can be dismissed — instead of archiving it and creating a new one (which lost the slug and any recorded dismissal state). · *Automatic-discount terms are editable from the console* — The discount detail screen now edits the rule's trigger and value — the amount off, percent off, cart threshold, or buy-x-get-y terms — which previously could only be changed through the API. The inline priority and on/off controls are unchanged. · *Subscription plans have an edit screen* — A subscription plan's price, billing interval count, trial length, active state, and variant can now be edited from a detail screen. The Stripe-bound fields (the price id, interval unit, and currency) remain read-only, as they were always immutable.
14
+
11
15
  - v0.3.18 (2026-05-30) — **Write and publish blog posts from the admin console.** The storefront already served a blog — the index at /blog, individual posts at /blog/:slug, an RSS feed, and sitemap entries — but there was no way to write a post, so it rendered empty. The admin console now has a Blog screen with the full lifecycle: create a draft, edit its title, body (Markdown), tags, hero image, and meta fields, publish it, unpublish it, and archive or restore it. A post is created as a draft and stays off the storefront until it is explicitly published, so work in progress is never visible to shoppers. **Added:** *Blog authoring in the admin console* — A new Blog screen lists posts by status and lets an operator create, edit, publish, unpublish, archive, and restore them. New posts start as drafts and do not appear on the storefront /blog, its RSS feed, or the sitemap until published; archiving removes a published post from the storefront and is reversible. This wires the admin over the blog backend that was already serving the customer-facing pages.
12
16
 
13
17
  - v0.3.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
package/lib/admin.js CHANGED
@@ -1086,6 +1086,18 @@ function mount(router, deps) {
1086
1086
 
1087
1087
  // ---- media ----------------------------------------------------------
1088
1088
 
1089
+ // True when media `mid` exists AND its product_id equals `productId` — the
1090
+ // self-consistency check the per-product media routes assert before acting,
1091
+ // so a `/admin/products/:id/media/:mid/...` request can't name product A in
1092
+ // the path while operating on product B's media. A malformed `mid` makes
1093
+ // catalog.media.get throw TypeError (the caller maps it to a clean 400);
1094
+ // an unknown id, a foreign-product id, or a variant-only row (no
1095
+ // product_id) returns false so the caller renders the same not-found.
1096
+ async function _mediaBelongsToProduct(mid, productId) {
1097
+ var row = await catalog.media.get(mid);
1098
+ return !!(row && row.product_id && row.product_id === productId);
1099
+ }
1100
+
1089
1101
  router.post("/admin/media", W("media.attach", async function (req, res) {
1090
1102
  var m = await catalog.media.attach(req.body || {});
1091
1103
  _json(res, 201, m);
@@ -1156,16 +1168,28 @@ function mount(router, deps) {
1156
1168
  // Fetch the source bytes. The framework's httpClient runs every
1157
1169
  // outbound through the SSRF gate, so a `source_url` pointing at
1158
1170
  // a cloud-metadata IP (169.254.169.254) / RFC 1918 host can't
1159
- // smuggle internal data into the bucket.
1171
+ // smuggle internal data into the bucket. `maxResponseBytes` caps the
1172
+ // buffered body at the same media budget the direct-file path enforces
1173
+ // (`_UPLOAD_MAX_BYTES`): without it the client buffers up to its
1174
+ // ~1 GiB GET default, so a `source_url` pointing at a multi-gigabyte
1175
+ // resource would balloon memory before the magic-byte sniff ever runs.
1176
+ // The client rejects with `RESPONSE_TOO_LARGE` the moment the body
1177
+ // crosses the cap; that surfaces below as a clean 413, never an OOM or
1178
+ // a 500.
1160
1179
  var fetched;
1161
1180
  try {
1162
1181
  fetched = await b.httpClient.request({
1163
- method: "GET",
1164
- url: body.source_url,
1165
- timeoutMs: 20000,
1166
- headers: { "accept": body.content_type + ",*/*;q=0.5" },
1182
+ method: "GET",
1183
+ url: body.source_url,
1184
+ timeoutMs: 20000,
1185
+ maxResponseBytes: _UPLOAD_MAX_BYTES,
1186
+ headers: { "accept": body.content_type + ",*/*;q=0.5" },
1167
1187
  });
1168
1188
  } catch (e) {
1189
+ if (e && e.code === "RESPONSE_TOO_LARGE") {
1190
+ return { status: 413, code: "source-too-large",
1191
+ detail: "source_url body exceeds the " + _UPLOAD_MAX_BYTES + "-byte media upload cap" };
1192
+ }
1169
1193
  return { status: 502, code: "source-fetch-failed", detail: (e && e.message) || String(e) };
1170
1194
  }
1171
1195
  if (fetched.statusCode < 200 || fetched.statusCode >= 300) {
@@ -1515,10 +1539,23 @@ function mount(router, deps) {
1515
1539
  // id is in the path, the product id only used to PRG back to the detail.
1516
1540
  // A malformed id throws TypeError (→ 400 / ?err=1); an unknown or
1517
1541
  // variant-only row returns false (→ 404 / ?err=1). DB-only.
1542
+ //
1543
+ // The :id (product) path segment is asserted against the media row's own
1544
+ // product_id before the promote: the primitive scopes its reorder by the
1545
+ // row's product, so naming product A in the path while pointing :mid at
1546
+ // product B's media would silently act on B. Refuse that mismatch (and a
1547
+ // variant-only row, which has no product to lead) with the same clean
1548
+ // not-found status, so the path is self-consistent and the contract
1549
+ // doesn't lie about which product it touched.
1518
1550
  router.post("/admin/products/:id/media/:mid/primary", _pageOrApi(false,
1519
1551
  W("media.set_primary", async function (req, res) {
1520
1552
  var ok;
1521
- try { ok = await catalog.media.setPrimary(req.params.mid); }
1553
+ try {
1554
+ if (!(await _mediaBelongsToProduct(req.params.mid, req.params.id))) {
1555
+ return _problem(res, 404, "media-not-found");
1556
+ }
1557
+ ok = await catalog.media.setPrimary(req.params.mid);
1558
+ }
1522
1559
  catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1523
1560
  if (!ok) return _problem(res, 404, "media-not-found");
1524
1561
  _json(res, 200, { ok: true });
@@ -1528,7 +1565,12 @@ function mount(router, deps) {
1528
1565
  var id = req.params.id;
1529
1566
  var enc = encodeURIComponent(id);
1530
1567
  var ok = false;
1531
- try { ok = await catalog.media.setPrimary(req.params.mid); }
1568
+ try {
1569
+ if (!(await _mediaBelongsToProduct(req.params.mid, id))) {
1570
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
1571
+ }
1572
+ ok = await catalog.media.setPrimary(req.params.mid);
1573
+ }
1532
1574
  catch (e) {
1533
1575
  _safeNotice(e, "media.set_primary");
1534
1576
  return _redirect(res, "/admin/products/" + enc + "?err=1");
@@ -4005,6 +4047,55 @@ function mount(router, deps) {
4005
4047
  },
4006
4048
  ));
4007
4049
 
4050
+ // Detail screen: the single announcement + its edit form. Content-
4051
+ // negotiates like the list — bearer → the JSON row; browser cookie →
4052
+ // the rendered edit page. A bad / unknown slug is a 404 page (browser)
4053
+ // or 404 problem (bearer), never a 500.
4054
+ router.get("/admin/announcements/:slug", _pageOrApi(true,
4055
+ R(async function (req, res) {
4056
+ var row = await announcements.getAnnouncement(req.params.slug);
4057
+ if (!row) return _problem(res, 404, "announcement-not-found");
4058
+ _json(res, 200, row);
4059
+ }),
4060
+ async function (req, res) {
4061
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4062
+ var row = await announcements.getAnnouncement(req.params.slug);
4063
+ if (!row) return _sendHtml(res, 404, renderAdminAnnouncements({
4064
+ shop_name: deps.shop_name, nav_available: navAvailable, announcements: [], notice: "Announcement not found.",
4065
+ }));
4066
+ _sendHtml(res, 200, renderAdminAnnouncement({
4067
+ shop_name: deps.shop_name, nav_available: navAvailable, announcement: row,
4068
+ updated: url && url.searchParams.get("updated"),
4069
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the announcement." : null,
4070
+ }));
4071
+ },
4072
+ ));
4073
+
4074
+ // Edit content-negotiates: bearer POST /edit (JSON) + browser POST
4075
+ // /edit (HTML forms can't PATCH). Both forward the full editable
4076
+ // column set — message, link, audience, schedule, dismissible — into
4077
+ // updateAnnouncement, preserving the slug + accumulated dismissal
4078
+ // state (archive-and-recreate would discard both). PRG to ?updated=1;
4079
+ // a bad shape is a clean 400 (bearer) / err notice (browser).
4080
+ router.post("/admin/announcements/:slug/edit", _pageOrApi(false,
4081
+ W("announcement.update", async function (req, res) {
4082
+ var row;
4083
+ try { row = await announcements.updateAnnouncement(req.params.slug, _announcementPatchFromForm(req.body || {})); }
4084
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4085
+ if (!row) return _problem(res, 404, "announcement-not-found");
4086
+ _json(res, 200, row);
4087
+ return { id: row.slug };
4088
+ }),
4089
+ async function (req, res) {
4090
+ var slug = req.params.slug;
4091
+ var enc = encodeURIComponent(slug);
4092
+ try { await announcements.updateAnnouncement(slug, _announcementPatchFromForm(req.body || {})); }
4093
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/announcements/" + enc + "?err=1"); }
4094
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.update", outcome: "success", metadata: { slug: slug } });
4095
+ _redirect(res, "/admin/announcements/" + enc + "?updated=1");
4096
+ },
4097
+ ));
4098
+
4008
4099
  router.post("/admin/announcements/:slug/archive", _pageOrApi(false,
4009
4100
  W("announcement.archive", async function (req, res) {
4010
4101
  var row;
@@ -4810,6 +4901,29 @@ function mount(router, deps) {
4810
4901
 
4811
4902
  var subscriptions = deps.subscriptions || null;
4812
4903
  if (subscriptions) {
4904
+ // Coerce the edit form into a plans.update patch. Only the mutable
4905
+ // columns are forwarded; an absent / blank field is omitted so a
4906
+ // partial edit leaves the rest untouched. amount_minor /
4907
+ // interval_count / trial_days go through the strict integer reader so
4908
+ // "", a float, or "12abc" is refused as a clean 400 rather than
4909
+ // coerced. active rides a hidden presence marker so a value-only edit
4910
+ // doesn't flip it. variant_id="" links the plan to no variant
4911
+ // (standalone); the immutable Stripe-bound columns are never on the
4912
+ // form.
4913
+ function _subscriptionPlanPatchFromForm(body) {
4914
+ body = body || {};
4915
+ var patch = {};
4916
+ if (body.amount_minor != null && body.amount_minor !== "") patch.amount_minor = _strictMinorInt(body.amount_minor, "subscriptions", "amount_minor (minor units)");
4917
+ if (body.interval_count != null && body.interval_count !== "") patch.interval_count = _strictMinorInt(body.interval_count, "subscriptions", "interval_count");
4918
+ if (body.trial_days != null && body.trial_days !== "") patch.trial_days = _strictMinorInt(body.trial_days, "subscriptions", "trial_days");
4919
+ if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
4920
+ if (Object.prototype.hasOwnProperty.call(body, "variant_id")) {
4921
+ var vid = typeof body.variant_id === "string" ? body.variant_id.trim() : body.variant_id;
4922
+ patch.variant_id = vid ? vid : null;
4923
+ }
4924
+ return patch;
4925
+ }
4926
+
4813
4927
  // Create content-negotiates: bearer → JSON (unchanged for tooling);
4814
4928
  // signed-in browser form → create, then PRG back to the catalog (a
4815
4929
  // bad-shape submit re-renders the form with the validator's message
@@ -4874,19 +4988,74 @@ function mount(router, deps) {
4874
4988
  },
4875
4989
  ));
4876
4990
 
4877
- router.get("/admin/subscription-plans/:id", R(async function (req, res) {
4878
- var p = await subscriptions.plans.get(req.params.id);
4879
- if (!p) return _problem(res, 404, "subscription-plan-not-found");
4880
- _json(res, 200, p);
4881
- }));
4991
+ // Detail screen content-negotiates: bearer the JSON plan (the
4992
+ // tooling contract, unchanged); browser cookie → the rendered detail
4993
+ // + edit page. A malformed / unknown id is a 404 either way, never a
4994
+ // 500. (Previously this route was bearer-JSON-only, so the console
4995
+ // had no way to change a plan's price / interval-count / trial after
4996
+ // create — only archive-and-recreate against a fresh Stripe price.)
4997
+ router.get("/admin/subscription-plans/:id", _pageOrApi(true,
4998
+ R(async function (req, res) {
4999
+ var p;
5000
+ try { p = await subscriptions.plans.get(req.params.id); }
5001
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "subscription-plan-not-found", e.message); throw e; }
5002
+ if (!p) return _problem(res, 404, "subscription-plan-not-found");
5003
+ _json(res, 200, p);
5004
+ }),
5005
+ async function (req, res) {
5006
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5007
+ var p = null;
5008
+ try { p = await subscriptions.plans.get(req.params.id); }
5009
+ catch (e) { if (!(e instanceof TypeError)) throw e; p = null; }
5010
+ if (!p) return _sendHtml(res, 404, renderAdminSubscriptionPlans({
5011
+ shop_name: deps.shop_name, nav_available: navAvailable, plans: [], notice: "Subscription plan not found.",
5012
+ }));
5013
+ _sendHtml(res, 200, renderAdminSubscriptionPlan({
5014
+ shop_name: deps.shop_name, nav_available: navAvailable, plan: p,
5015
+ updated: url && url.searchParams.get("updated"),
5016
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the plan." : null,
5017
+ }));
5018
+ },
5019
+ ));
4882
5020
 
4883
5021
  router.patch("/admin/subscription-plans/:id", W("subscription_plan.update", async function (req, res) {
4884
- var p = await subscriptions.plans.update(req.params.id, req.body || {});
5022
+ var p;
5023
+ try { p = await subscriptions.plans.update(req.params.id, req.body || {}); }
5024
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4885
5025
  if (!p) return _problem(res, 404, "subscription-plan-not-found");
4886
5026
  _json(res, 200, p);
4887
5027
  return p;
4888
5028
  }));
4889
5029
 
5030
+ // Browser edit alias for the PATCH (HTML forms can't PATCH). Forwards
5031
+ // the mutable columns — amount / interval_count / trial_days / active
5032
+ // / variant_id — through plans.update; the immutable Stripe-bound
5033
+ // columns (stripe_price_id / interval / currency) are not on the form
5034
+ // (changing those is archive-and-recreate). A bad value is a clean
5035
+ // 400 (bearer) / err notice (browser), never a 500 or partial write.
5036
+ router.post("/admin/subscription-plans/:id/edit", _pageOrApi(false,
5037
+ W("subscription_plan.update", async function (req, res) {
5038
+ var p;
5039
+ try { p = await subscriptions.plans.update(req.params.id, _subscriptionPlanPatchFromForm(req.body || {})); }
5040
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5041
+ if (!p) return _problem(res, 404, "subscription-plan-not-found");
5042
+ _json(res, 200, p);
5043
+ return p;
5044
+ }),
5045
+ async function (req, res) {
5046
+ var id = req.params.id;
5047
+ var enc = encodeURIComponent(id);
5048
+ var updated = null;
5049
+ try { updated = await subscriptions.plans.update(id, _subscriptionPlanPatchFromForm(req.body || {})); }
5050
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1"); }
5051
+ // A well-formed-but-unknown id (stale / tampered form) updates no
5052
+ // row — plans.update returns null. Flag err, never a false success.
5053
+ if (!updated) return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1");
5054
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.update", outcome: "success", metadata: { id: id } });
5055
+ _redirect(res, "/admin/subscription-plans/" + enc + "?updated=1");
5056
+ },
5057
+ ));
5058
+
4890
5059
  // Browser confirmation interstitial for archive — terminal from the
4891
5060
  // console (a retired plan is replaced by creating a new one against a
4892
5061
  // fresh Stripe price id, never reactivated in place), so confirm it.
@@ -5858,6 +6027,30 @@ function mount(router, deps) {
5858
6027
  return { id: rule.slug };
5859
6028
  }));
5860
6029
 
6030
+ // Detail screen: the single rule + a full edit form covering the
6031
+ // trigger + value terms (not just priority/active). Content-
6032
+ // negotiates like the list — bearer → the JSON rule; browser cookie
6033
+ // → the rendered edit page.
6034
+ router.get("/admin/discounts/:slug", _pageOrApi(true,
6035
+ R(async function (req, res) {
6036
+ var rule = await autoDiscount.getRule(req.params.slug);
6037
+ if (!rule) return _problem(res, 404, "auto-discount-not-found");
6038
+ _json(res, 200, rule);
6039
+ }),
6040
+ async function (req, res) {
6041
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6042
+ var rule = await autoDiscount.getRule(req.params.slug);
6043
+ if (!rule) return _sendHtml(res, 404, await _renderDiscounts({
6044
+ notice: "Discount rule not found.",
6045
+ }));
6046
+ _sendHtml(res, 200, renderAdminDiscount({
6047
+ shop_name: deps.shop_name, nav_available: navAvailable, rule: rule,
6048
+ updated: url && url.searchParams.get("updated"),
6049
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rule." : null,
6050
+ }));
6051
+ },
6052
+ ));
6053
+
5861
6054
  router.post("/admin/discounts/:slug/edit", _pageOrApi(false,
5862
6055
  W("auto_discount.update", async function (req, res) {
5863
6056
  var rule;
@@ -5867,9 +6060,18 @@ function mount(router, deps) {
5867
6060
  return { id: rule.slug };
5868
6061
  }),
5869
6062
  async function (req, res) {
5870
- try { await autoDiscount.updateRule(req.params.slug, _discountPatch(req.body || {})); }
5871
- catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/discounts?err=1"); }
5872
- b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: req.params.slug } });
6063
+ var slug = req.params.slug;
6064
+ // A terms edit (trigger/value) comes from the detail screen and
6065
+ // returns there on error so the operator sees their input in
6066
+ // context; the inline row edit (priority/active only) stays on
6067
+ // the list. Success PRGs to the list's ?updated banner either way.
6068
+ var fromDetail = (req.body && (req.body.trigger_kind || req.body.value_kind));
6069
+ var errHref = fromDetail
6070
+ ? "/admin/discounts/" + encodeURIComponent(slug) + "?err=1"
6071
+ : "/admin/discounts?err=1";
6072
+ try { await autoDiscount.updateRule(slug, _discountPatch(req.body || {})); }
6073
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, errHref); }
6074
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: slug } });
5873
6075
  _redirect(res, "/admin/discounts?updated=1");
5874
6076
  },
5875
6077
  ));
@@ -6263,15 +6465,24 @@ function mount(router, deps) {
6263
6465
  return { kind: "free_shipping" };
6264
6466
  }
6265
6467
 
6266
- // Translate the rule edit form into an updateRule patch title /
6267
- // priority / active. The trigger + value re-set go through the bearer
6268
- // JSON PATCH for the full vocabulary; the console edit is the common
6269
- // "rename / re-prioritise / pause" gesture.
6468
+ // Translate the rule edit form into an updateRule patch. The inline
6469
+ // row form covers the common "rename / re-prioritise / pause" gesture
6470
+ // (title / priority / active); the detail screen additionally posts a
6471
+ // trigger_kind + value_kind so an operator can change the actual
6472
+ // discount terms (amount / percentage / threshold / BOGO) from the
6473
+ // console, not just reprioritise. The trigger / value kind fields are
6474
+ // reused from the create form's vocabulary via _discountTrigger /
6475
+ // _discountValue, which throw a TypeError on a bad / missing required
6476
+ // field — so a bad terms edit degrades to a clean 400, never a 500,
6477
+ // and never a silent partial write. The richer applies_to / segment /
6478
+ // exclusion vocabulary stays on the bearer JSON PATCH.
6270
6479
  function _discountPatch(body) {
6271
6480
  var patch = {};
6272
6481
  if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
6273
6482
  if (body.priority != null && body.priority !== "") patch.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
6274
6483
  if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
6484
+ if (body.trigger_kind != null && body.trigger_kind !== "") patch.trigger = _discountTrigger(body);
6485
+ if (body.value_kind != null && body.value_kind !== "") patch.value = _discountValue(body);
6275
6486
  return patch;
6276
6487
  }
6277
6488
 
@@ -8163,6 +8374,56 @@ function renderAdminInventory(opts) {
8163
8374
  return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
8164
8375
  }
8165
8376
 
8377
+ // Single subscription-plan detail + edit form. The Stripe-bound columns
8378
+ // (price id / interval / currency) are immutable post-create — shown
8379
+ // read-only — because they mirror a recurring Stripe Price; to change
8380
+ // those an operator archives the plan and creates a new one against a
8381
+ // fresh price id. The mutable columns (amount / interval count / trial /
8382
+ // active / variant link) are editable here.
8383
+ function renderAdminSubscriptionPlan(opts) {
8384
+ opts = opts || {};
8385
+ var p = opts.plan;
8386
+ if (!p) {
8387
+ var nf = "<section><h2>Subscription plan</h2><p class=\"empty\">Subscription plan not found.</p>" +
8388
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
8389
+ return _renderAdminShell(opts.shop_name, "Subscription plan", nf, "subscriptions", opts.nav_available);
8390
+ }
8391
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Plan updated.</div>" : "";
8392
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8393
+ var isActive = p.active === 1 || p.active === true;
8394
+ var cur = String(p.currency || "").toUpperCase();
8395
+ var every = p.interval_count > 1 ? p.interval_count + " " + p.interval + "s" : p.interval;
8396
+
8397
+ var summary =
8398
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
8399
+ "<div><dt>Price</dt><dd><strong>" + _htmlEscape(pricing.format(p.amount_minor, cur) + " / " + every) + "</strong></dd></div>" +
8400
+ "<div><dt>Stripe price</dt><dd><code class=\"order-id\">" + _htmlEscape(p.stripe_price_id) + "</code></dd></div>" +
8401
+ "<div><dt>Variant</dt><dd>" + (p.variant_id ? "<code class=\"order-id\">" + _htmlEscape(String(p.variant_id)) + "</code>" : "<span class=\"meta\">standalone</span>") + "</dd></div>" +
8402
+ "<div><dt>Trial</dt><dd>" + (p.trial_days ? _htmlEscape(String(p.trial_days)) + " days" : "<span class=\"meta\">none</span>") + "</dd></div>" +
8403
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "archived") + "</span></dd></div>" +
8404
+ "</dl></div>";
8405
+
8406
+ var editForm = !isActive
8407
+ ? "<p class=\"empty\">This plan is archived and can no longer be edited. Create a new plan against a fresh Stripe price id to offer it again.</p>"
8408
+ : "<div class=\"panel mw-34\">" +
8409
+ "<h3 class=\"subhead\">Edit plan</h3>" +
8410
+ "<p class=\"meta\">The Stripe price id, billing interval, and currency are fixed — they mirror the Stripe Price. To change those, archive this plan and create a new one.</p>" +
8411
+ "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/edit\">" +
8412
+ "<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
8413
+ _setupField("Amount (minor units)", "amount_minor", String(p.amount_minor), "number", "In the currency's smallest unit — e.g. 1999 = $19.99.", " min=\"1\"") +
8414
+ _setupField("Interval count", "interval_count", String(p.interval_count), "number", "Bill every N " + _htmlEscape(p.interval) + "s (1–12).", " min=\"1\" max=\"12\"") +
8415
+ _setupField("Trial days", "trial_days", String(p.trial_days), "number", "Free trial length before the first charge (0–730).", " min=\"0\" max=\"730\"") +
8416
+ _setupField("Variant id (optional)", "variant_id", p.variant_id ? String(p.variant_id) : "", "text", "Link to a storefront variant, or clear for a standalone tier.", " maxlength=\"64\"") +
8417
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"active\" checked> Active</label>" +
8418
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
8419
+ "</form>" +
8420
+ "</div>";
8421
+
8422
+ var bodyHtml = "<section><h2>Subscription plan</h2>" + updated + notice + summary + editForm +
8423
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
8424
+ return _renderAdminShell(opts.shop_name, "Subscription plan", bodyHtml, "subscriptions", opts.nav_available);
8425
+ }
8426
+
8166
8427
  function renderAdminSubscriptionPlans(opts) {
8167
8428
  opts = opts || {};
8168
8429
  var rows = opts.plans || [];
@@ -8188,9 +8449,10 @@ function renderAdminSubscriptionPlans(opts) {
8188
8449
  var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
8189
8450
  var isActive = p.active === 1 || p.active === true;
8190
8451
  var archiveCell = isActive
8191
- ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
8452
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">Edit</a> " +
8453
+ "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
8192
8454
  "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
8193
- : "<span class=\"meta\">—</span>";
8455
+ : "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">View</a>";
8194
8456
  return "<tr>" +
8195
8457
  "<td><strong>" + _htmlEscape(price) + "</strong>" +
8196
8458
  (p.trial_days ? " <span class=\"status-pill pending\">" + _htmlEscape(String(p.trial_days)) + "d trial</span>" : "") + "</td>" +
@@ -8733,6 +8995,83 @@ function _fmtValue(v) {
8733
8995
  return v.kind;
8734
8996
  }
8735
8997
 
8998
+ // Single auto-discount rule detail + a full edit form. Unlike the
8999
+ // inline row form (priority/active only), this form re-collects the
9000
+ // trigger + value terms so an operator can change the amount / percent
9001
+ // / threshold / BOGO quantities from the console. The trigger / value
9002
+ // field set mirrors the create form exactly; each kind's fields are
9003
+ // prefilled from the rule's current terms, and the operator fills the
9004
+ // ones the chosen kind needs (the backend validates the required
9005
+ // fields for the kind). The hidden active_present marker keeps a
9006
+ // terms-only edit from flipping active.
9007
+ function renderAdminDiscount(opts) {
9008
+ opts = opts || {};
9009
+ var r = opts.rule;
9010
+ if (!r) {
9011
+ var nf = "<section><h2>Discount rule</h2><p class=\"empty\">Discount rule not found.</p>" +
9012
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div></section>";
9013
+ return _renderAdminShell(opts.shop_name, "Discount rule", nf, "discounts", opts.nav_available);
9014
+ }
9015
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Rule updated.</div>" : "";
9016
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9017
+ var isArchived = r.archived_at != null;
9018
+ var t = r.trigger || {};
9019
+ var v = r.value || {};
9020
+
9021
+ var TRIGGERS = [
9022
+ { value: "cart_total_min", label: "Cart total ≥ (minor)" },
9023
+ { value: "item_count_min", label: "Item count ≥" },
9024
+ { value: "sku_purchase", label: "SKU purchased" },
9025
+ ];
9026
+ var VALUES = [
9027
+ { value: "percent_off", label: "Percent off" },
9028
+ { value: "amount_off_total", label: "Amount off total" },
9029
+ { value: "amount_off_each", label: "Amount off each" },
9030
+ { value: "free_shipping", label: "Free shipping" },
9031
+ { value: "bogo", label: "Buy X get Y" },
9032
+ ];
9033
+ // For amount_off_total / amount_off_each the lib stores one `minor`
9034
+ // field; prefill it for whichever amount kind is active.
9035
+ var amountMinor = (v.kind === "amount_off_total" || v.kind === "amount_off_each") && v.minor != null ? String(v.minor) : "";
9036
+
9037
+ var editForm = isArchived
9038
+ ? "<p class=\"empty\">This rule is archived and can no longer be edited.</p>"
9039
+ : "<div class=\"panel mw-42\">" +
9040
+ "<h3 class=\"subhead\">Edit rule</h3>" +
9041
+ "<p class=\"meta\">Change the trigger, the value, or the priority. Fill the fields for the trigger / value kind you pick — the others are ignored.</p>" +
9042
+ "<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/edit\">" +
9043
+ "<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
9044
+ _setupField("Title", "title", r.title, "text", "Operator-facing name.", " maxlength=\"200\"") +
9045
+ _selectField("Trigger kind", "trigger_kind", TRIGGERS, t.kind || "cart_total_min", "What fires the rule.", "") +
9046
+ _setupField("· Cart total min (minor)", "trigger_min_minor", t.kind === "cart_total_min" && t.min_minor != null ? String(t.min_minor) : "", "number", "For \"Cart total ≥\".", " min=\"0\"") +
9047
+ _setupField("· Item count min", "trigger_min_count", t.kind === "item_count_min" && t.min_count != null ? String(t.min_count) : "", "number", "For \"Item count ≥\".", " min=\"1\"") +
9048
+ _setupField("· SKUs (comma-separated)", "trigger_skus", t.kind === "sku_purchase" && Array.isArray(t.skus) ? t.skus.join(",") : "", "text", "For \"SKU purchased\".", " maxlength=\"2000\"") +
9049
+ _setupField("· SKU min quantity", "trigger_min_quantity", t.kind === "sku_purchase" && t.min_quantity != null ? String(t.min_quantity) : "", "number", "For \"SKU purchased\" (default 1).", " min=\"1\"") +
9050
+ _selectField("Value kind", "value_kind", VALUES, v.kind || "percent_off", "What the rule gives.", "") +
9051
+ _setupField("· Percent (basis points)", "value_basis_points", v.kind === "percent_off" && v.basis_points != null ? String(v.basis_points) : "", "number", "For \"Percent off\". 1000 = 10.00%.", " min=\"1\" max=\"10000\"") +
9052
+ _setupField("· Amount (minor)", "value_minor", amountMinor, "number", "For \"Amount off total / each\".", " min=\"1\"") +
9053
+ _setupField("· BOGO buy qty", "value_buy_qty", v.kind === "bogo" && v.buy_qty != null ? String(v.buy_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
9054
+ _setupField("· BOGO get qty", "value_get_qty", v.kind === "bogo" && v.get_qty != null ? String(v.get_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
9055
+ _setupField("Priority", "priority", String(r.priority), "number", "Higher wins ties.", " min=\"0\"") +
9056
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> Active</label>" +
9057
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
9058
+ "</form>" +
9059
+ "</div>";
9060
+
9061
+ var bodyHtml = "<section><h2>Discount rule</h2>" + updated + notice +
9062
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
9063
+ "<div><dt>Rule</dt><dd><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></dd></div>" +
9064
+ "<div><dt>Trigger</dt><dd>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</dd></div>" +
9065
+ "<div><dt>Value</dt><dd>" + _htmlEscape(_fmtValue(r.value)) + "</dd></div>" +
9066
+ "<div><dt>Priority</dt><dd>" + _htmlEscape(String(r.priority)) + "</dd></div>" +
9067
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : (r.active ? "paid" : "pending")) + "\">" + (isArchived ? "archived" : (r.active ? "active" : "paused")) + "</span></dd></div>" +
9068
+ "</dl></div>" +
9069
+ editForm +
9070
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div>" +
9071
+ "</section>";
9072
+ return _renderAdminShell(opts.shop_name, "Discount rule", bodyHtml, "discounts", opts.nav_available);
9073
+ }
9074
+
8736
9075
  function renderAdminDiscounts(opts) {
8737
9076
  opts = opts || {};
8738
9077
  var rules = opts.rules || [];
@@ -8755,6 +9094,7 @@ function renderAdminDiscounts(opts) {
8755
9094
  "<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> on</label>" +
8756
9095
  "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
8757
9096
  "</form> " +
9097
+ "<a class=\"btn btn--ghost\" href=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "\">Edit terms</a> " +
8758
9098
  "<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/archive\" class=\"form-inline\">" +
8759
9099
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
8760
9100
  return "<tr>" +
@@ -9249,6 +9589,112 @@ function _announcementFromForm(body) {
9249
9589
  return out;
9250
9590
  }
9251
9591
 
9592
+ // Coerce the edit form into an updateAnnouncement patch. Every editable
9593
+ // column the primitive's whitelist accepts is forwarded so a console
9594
+ // edit reaches the same surface as the bearer PATCH: message, theme,
9595
+ // audience, dismissible, the link pair, and the two schedule bounds.
9596
+ // The schedule + link fields use the form's hidden presence markers so
9597
+ // a partial edit doesn't unintentionally clear an unrelated column: a
9598
+ // link_present marker maps a blank pair to null (clear the link); a
9599
+ // starts_present / expires_present marker maps a blank bound to null
9600
+ // (open the schedule). Absent markers leave the column untouched. The
9601
+ // primitive validates the result (audience<->segment, link both-or-
9602
+ // neither, expires>starts) and throws a TypeError on a bad shape, which
9603
+ // both surfaces degrade to a clean 400 / err notice.
9604
+ function _announcementPatchFromForm(body) {
9605
+ body = body || {};
9606
+ var patch = {};
9607
+ if (Object.prototype.hasOwnProperty.call(body, "message") && body.message !== "") patch.message = body.message;
9608
+ if (body.theme != null && body.theme !== "") patch.theme = body.theme;
9609
+ if (body.audience != null && body.audience !== "") patch.audience = body.audience;
9610
+ // The dismissible checkbox is present (on/1) when checked, absent when
9611
+ // unchecked — only treat it as a field when the form declared the
9612
+ // hidden marker so a partial JSON edit doesn't flip it.
9613
+ if (body.dismissible_present === "1") patch.dismissible = (body.dismissible === "on" || body.dismissible === "1");
9614
+ if (body.link_present === "1") {
9615
+ var lu = typeof body.link_url === "string" ? body.link_url.trim() : "";
9616
+ var ll = typeof body.link_label === "string" ? body.link_label.trim() : "";
9617
+ if (lu || ll) { patch.link_url = lu; patch.link_label = ll; }
9618
+ else { patch.link_url = null; patch.link_label = null; }
9619
+ }
9620
+ if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
9621
+ if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
9622
+ return patch;
9623
+ }
9624
+
9625
+ // epoch ms → the <datetime-local> value an <input> renders back. Returns
9626
+ // "" for a null/absent bound so the field stays empty (open-ended).
9627
+ function _datetimeLocalValue(epochMs) {
9628
+ if (epochMs == null) return "";
9629
+ var d = new Date(Number(epochMs));
9630
+ if (isNaN(d.getTime())) return "";
9631
+ function _pad(n) { return n < 10 ? "0" + n : String(n); }
9632
+ return d.getUTCFullYear() + "-" + _pad(d.getUTCMonth() + 1) + "-" + _pad(d.getUTCDate()) +
9633
+ "T" + _pad(d.getUTCHours()) + ":" + _pad(d.getUTCMinutes());
9634
+ }
9635
+
9636
+ // Single-announcement detail + edit screen. The form prefills every
9637
+ // editable column and posts to /edit; the hidden presence markers tell
9638
+ // the patch coercion which columns the form is authoritative for, so a
9639
+ // blank schedule / link clears rather than being ignored.
9640
+ function renderAdminAnnouncement(opts) {
9641
+ opts = opts || {};
9642
+ var a = opts.announcement;
9643
+ if (!a) {
9644
+ var nf = "<section><h2>Announcement</h2><p class=\"empty\">Announcement not found.</p>" +
9645
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div></section>";
9646
+ return _renderAdminShell(opts.shop_name, "Announcement", nf, "announcements", opts.nav_available);
9647
+ }
9648
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Announcement updated.</div>" : "";
9649
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9650
+ var isArchived = a.archived_at != null;
9651
+
9652
+ var themeOpts = ["urgency", "promo", "info", "success"].map(function (t) {
9653
+ return "<option value=\"" + t + "\"" + (t === a.theme ? " selected" : "") + ">" + t + "</option>";
9654
+ }).join("");
9655
+ // segment audience needs an isMember handle that isn't wired in the
9656
+ // console (see the create screen's note), so the picker offers the
9657
+ // three reachable audiences; an existing segment row keeps its value
9658
+ // as a disabled-looking selected option so an edit doesn't silently
9659
+ // re-target it.
9660
+ var audValues = ["all", "guest", "logged_in"];
9661
+ if (a.audience === "segment") audValues = ["segment"].concat(audValues);
9662
+ var audienceOpts = audValues.map(function (au) {
9663
+ return "<option value=\"" + au + "\"" + (au === a.audience ? " selected" : "") + ">" + au + "</option>";
9664
+ }).join("");
9665
+
9666
+ var editForm = isArchived
9667
+ ? "<p class=\"empty\">This announcement is archived and can no longer be edited.</p>"
9668
+ : "<div class=\"panel mw-40\">" +
9669
+ "<h3 class=\"subhead\">Edit announcement</h3>" +
9670
+ "<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/edit\">" +
9671
+ "<input type=\"hidden\" name=\"dismissible_present\" value=\"1\">" +
9672
+ "<input type=\"hidden\" name=\"link_present\" value=\"1\">" +
9673
+ "<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
9674
+ "<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
9675
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"message\" maxlength=\"500\" required>" + _htmlEscape(a.message) + "</textarea></label>" +
9676
+ _setupField("Link URL (optional)", "link_url", a.link_url || "", "text", "https:// or a /-rooted path. Clear both to remove the link.", " maxlength=\"2048\"") +
9677
+ _setupField("Link label (optional)", "link_label", a.link_label || "", "text", "", " maxlength=\"120\"") +
9678
+ "<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
9679
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
9680
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.starts_at)) + "\"></label>" +
9681
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.expires_at)) + "\"></label>" +
9682
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"dismissible\" value=\"on\"" + (a.dismissible ? " checked" : "") + "> Visitors can dismiss this</label>" +
9683
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
9684
+ "</form>" +
9685
+ "</div>";
9686
+
9687
+ var bodyHtml = "<section><h2>Announcement</h2>" + updated + notice +
9688
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
9689
+ "<div><dt>Slug</dt><dd><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></dd></div>" +
9690
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></dd></div>" +
9691
+ "</dl></div>" +
9692
+ editForm +
9693
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div>" +
9694
+ "</section>";
9695
+ return _renderAdminShell(opts.shop_name, "Announcement", bodyHtml, "announcements", opts.nav_available);
9696
+ }
9697
+
9252
9698
  function renderAdminAnnouncements(opts) {
9253
9699
  opts = opts || {};
9254
9700
  var rows = opts.announcements || [];
@@ -9272,6 +9718,7 @@ function renderAdminAnnouncements(opts) {
9272
9718
  "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
9273
9719
  "<td><div class=\"actions-row\">" +
9274
9720
  (isArchived ? "" :
9721
+ "<a class=\"btn btn--ghost\" href=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "\">Edit</a> " +
9275
9722
  "<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/archive\" class=\"form-inline\">" +
9276
9723
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
9277
9724
  "</div></td>" +
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.18",
2
+ "version": "0.3.20",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -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.14.8",
7
- "tag": "v0.14.8",
6
+ "version": "0.14.9",
7
+ "tag": "v0.14.9",
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.14.x
10
10
 
11
+ - v0.14.9 (2026-05-30) — **Corrects EU AI Act doc paths that named an uncallable namespace, plus source-comment hygiene and two new codebase checks.** A documentation fix and internal hygiene. The `@primitive` / `@signature` / `@example` blocks for the EU AI Act fundamental-rights-impact-assessment and GPAI training-data-summary helpers advertised `b.complianceAiAct.*`, which is undefined — the callable path is `b.compliance.aiAct.*` — so an operator copying the documented call got `undefined is not a function`. The documented paths now match the real surface. Alongside that: a duplicate parser entry in a doc block is removed, version stamps embedded in section-divider comments are stripped, and two codebase checks are added — one that fails the build when a `@primitive` block documents a wholly-unresolvable namespace (the gap that hid the AI Act paths), and one that flags a version stamp left inside a section divider. No exported API, error code, wire format, or runtime behaviour changes. **Changed:** *Source-comment hygiene* — Removed a duplicate `env` entry from the parsers `@module` doc block, and stripped internal version stamps (`vX.Y.Z`) from `// ---- ... ----` section-divider comments across several files, keeping the descriptive label. Comment-only; no behaviour change. **Fixed:** *EU AI Act helper documentation named an uncallable path* — `b.compliance.aiAct.fundamentalRightsImpactAssessment` and `b.compliance.aiAct.gpai.trainingDataSummary` were documented as `b.complianceAiAct.*` in their `@primitive` / `@signature` / `@example` blocks (and one returned reference string). `b.complianceAiAct` is undefined, so the documented call failed; the documented paths now match the callable surface. **Detectors:** *`@primitive` reachability covers wrong-namespace paths* — The reachability check previously only flagged a missing leaf on a resolved namespace; a `@primitive` whose entire dotted prefix is unresolvable (the shape that hid the AI Act doc paths) was silently skipped. It now walks each prefix segment and fails the build on any unresolvable one, while preserving the factory-instance-shorthand exemption. · *Version-stamp-in-divider check* — A new check flags a version stamp (`vX.Y.Z`) left immediately after a section divider's dashes (`// ---- vX.Y.Z ...`) — internal release vocabulary that does not belong in shipped source comments — without matching legitimate `@since` tags or prose version references.
12
+
11
13
  - v0.14.8 (2026-05-30) — **Source-comment and codebase-check hygiene, plus a new require-block alignment check; no API or behaviour changes.** Internal lint and comment cleanup with no operator-facing surface change. Several codebase-check comments and one stub helper name that described behaviour the check no longer has are corrected; an unused lint-suppression class and a set of stale duplicate-cluster qualifiers (functions that were since renamed or extracted) are pruned or re-pointed. Fifty-nine `// allow:` markers that named the byte-size or time-literal check on values those checks no longer flag are removed, and twenty self-negating rationales on markers the time check genuinely fires on are rewritten to say why the value coincidentally matches. A new codebase check holds top-of-file require blocks to consistent `=` column alignment, with the files that currently carry drift listed as a migration allowlist. No exported API, error code, wire format, or runtime behaviour changes. **Changed:** *Lint-suppression and codebase-check comment cleanup* — Corrected codebase-check comments that overstated their check's scope (a duplicate-code threshold described as three files when the advisory threshold is two, a narrowed byte-literal check carrying its pre-narrowing description, and a deferred-scan helper named as though it enforced a guarantee it does not yet provide). Removed an unused lint-suppression class and its one dead in-code marker, and pruned or re-pointed stale duplicate-cluster qualifiers that named functions since renamed or extracted into shared helpers. Removed fifty-nine `// allow:` markers that suppressed nothing, and rewrote twenty self-negating marker rationales (which read "not seconds" while sitting on a value the time check fires on) to explain the coincidental match. Source-comment and test hygiene only. **Detectors:** *Require-block `=` alignment check* — A new codebase check flags a top-of-file require block that mixes its `=` column alignment — a fittable line whose `=` drifts off the column the rest of the block shares. Compact single-space blocks are exempt (only blocks that declare alignment intent are checked), as are destructures and long names physically too wide to reach the column, and blank- or comment-separated tiers align independently. The files that currently carry such drift are an explicit migration allowlist, reflowed over time; new code is held to the rule.
12
14
 
13
15
  - v0.14.7 (2026-05-30) — **Storage and audit-trail hardening: queries are gated to declared columns, raw SQL refuses embedded literals, the database key is bound to its location, sealed-column lookup hashes gain a keyed mode, audit-chain purges can require dual control, and breach deadlines ship a running clock.** This release tightens the data and audit layers against a set of failure modes that were previously reachable. Database queries are now checked against the columns a table declared in its schema: a reference to an undeclared column fails closed by default instead of silently matching nothing, and the `whereRaw` escape hatch refuses an embedded string literal so values bind through placeholders. The database encryption key is sealed with its purpose, data directory, and key path as additional authenticated data, so a key file cannot be relocated to another deployment and unsealed there; an older key without that binding upgrades itself on first load. Sealed-column equality-lookup hashes can now be computed as a keyed MAC (HMAC-SHAKE256) off a per-deployment key, making the lookup hash unforgeable without that key, while the salted-SHA3 default is unchanged. Purging the tamper-evident audit chain can be placed under a two-authorizer dual-control grant so one operator cannot erase it alone, and database credential-rejection audits now record which relation the rejected credential tried to reach. Finally, breach-notification deadlines get a running clock that raises approaching and passed alerts as each regime's window elapses. One behavior change to note: the column gate defaults to reject — if a service issues queries against columns it did not declare in its schema, set `db.init({ columnGate: "warn" })` (audited, allowed) or `"off"` while the schema is reconciled. **Added:** *Column-membership gate on every query* — `b.db.from(table)` now checks each referenced column against the table's declared schema. The mode is set with `db.init({ columnGate: "reject" | "warn" | "off" })` (default `reject`), and `query.allowedColumns([...])` narrows a single query to an explicit allowlist that is always enforced. `b.db.getDeclaredColumns(table)` returns a table's declared column names (or `null` for an unknown table). This is defense in depth against typo'd or caller-influenced column names reaching the SQL layer (CWE-89). · *Keyed mode for sealed-column lookup hashes* — Equality-lookup ("derived") hashes for sealed columns can be computed as `hmac-shake256` — a keyed MAC over a per-deployment key — instead of the default `salted-sha3`. Set it per table with `cryptoField.registerTable(name, { derivedHashMode: "hmac-shake256" })` or per column with `{ from, mode: "hmac-shake256" }`. The keyed hash is unforgeable and un-correlatable without the deployment's MAC key, which raises the bar against offline lookup-table attacks on low-entropy sealed values (CWE-916). `b.vault.getDerivedHashMacKey()` exposes the 32-byte per-deployment key; it is created on first use and re-sealed across key rotation automatically. · *Dual-control gate on audit-chain purge* — `b.auditTools.purge` accepts `dualControlGrant`. When `audit_log` is placed under dual control, a verified archive and `confirm: true` are no longer sufficient: the purge additionally requires a consumed m-of-n grant whose action is bound to the purge, so a grant minted for another operation cannot be replayed and one operator cannot erase the tamper-evident chain alone (NIST SP 800-53 AU-9, separation of duties). · *Running clock for breach-notification deadlines* — `b.incident.report.createDeadlineClock({ notify, approachThresholds })` tracks open incidents and raises `deadline_approaching` and `deadline_passed` alerts as each regime's window elapses (GDPR 72h, DORA, NIS2, and the rest of the registry). Alerts are deduplicated per incident and stage, suppressed once a submission stage is acknowledged, and the clock can run on an interval or be ticked manually. **Changed:** *Queries against undeclared columns now fail closed by default* — The column gate defaults to `reject`: a query that references a column the table did not declare throws rather than silently matching nothing. A service that intentionally queries undeclared columns can set `db.init({ columnGate: "warn" })` to audit and allow, or `"off"` to disable the gate, while its schema is reconciled. Framework-declared columns (including `_id` and derived-hash columns) are always members. **Security:** *Database encryption key bound to its location* — `db.key.enc` is sealed with additional authenticated data over its purpose, resolved data directory, and resolved key path. A sealed key copied to a different deployment or path no longer unseals there — the AEAD authentication fails — which prevents silent key relocation. A legacy key sealed without this binding is detected and re-sealed in the bound format on first load, with no operator action required. · *`whereRaw` refuses embedded string literals* — `whereRaw(sql, params)` and `WhereBuilder.raw(sql, params)` reject a raw fragment containing a string literal (`'...'`); values must bind through the `params` array. A static, operator-controlled literal can opt in with `{ allowLiterals: true }`. This closes a path where a value concatenated into a raw fragment would reintroduce SQL injection (CWE-89). · *Credential-rejection audits record the attempted relation* — A `db.auth.failed` audit row (SQLSTATE 28000 / 28P01 / 42501) now carries `attemptedTable`, the relation the rejected credential tried to reach, extracted defensively from the statement. Triage can scope the blast radius of a credential-abuse event without correlating back to the raw SQL log (CWE-778). **Detectors:** *Audit-purge dual-control gate* — A new check fails the build if a call to `purgeAuditChain` appears in a file that does not also route through the dual-control gate, so a future caller cannot physically delete chain rows without two-authorizer enforcement. · *Raw-SQL literal/interpolation guard* — A new check fails the build on a `whereRaw` / `.raw` call whose SQL argument is built by template interpolation or string concatenation, keeping the bound-params discipline enforceable in framework code. · *Hand-rolled lookup-hash guard* — A new check fails the build if a sealed-column lookup hash is derived from the per-deployment salt outside the canonical helper, so call sites cannot bypass the keyed-mode and per-column mode policy. · *Auth-audit attempted-relation guard* — A new check fails the build if a `db.auth.failed` audit is emitted in a file that does not name `attemptedTable`, so the forensic field cannot be dropped from a future emitter.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.14.8",
4
- "createdAt": "2026-05-30T23:08:17.846Z",
3
+ "frameworkVersion": "0.14.9",
4
+ "createdAt": "2026-05-31T03:42:29.026Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -842,7 +842,7 @@ function create(opts) {
842
842
  "</md:EntityDescriptor>";
843
843
  }
844
844
 
845
- // ---- v0.10.16 — Single Logout (RFC SAML Bindings §3.4 HTTP-Redirect) ----
845
+ // ---- Single Logout (RFC SAML Bindings §3.4 HTTP-Redirect) ----
846
846
 
847
847
  /**
848
848
  * @primitive b.auth.saml.sp.buildLogoutRequest
@@ -1250,7 +1250,7 @@ function create(opts) {
1250
1250
  };
1251
1251
  }
1252
1252
 
1253
- // ---- v0.10.16 — SLO HTTP-POST binding (SAML Bindings §3.5) ----
1253
+ // ---- SLO HTTP-POST binding (SAML Bindings §3.5) ----
1254
1254
 
1255
1255
  /**
1256
1256
  * @primitive b.auth.saml.sp.buildLogoutRequestPost
@@ -1524,7 +1524,7 @@ function create(opts) {
1524
1524
  };
1525
1525
  }
1526
1526
 
1527
- // ---- v0.10.16 — SAML EncryptedAssertion decrypt (XMLEnc) ----
1527
+ // ---- SAML EncryptedAssertion decrypt (XMLEnc) ----
1528
1528
 
1529
1529
  // XMLEnc Algorithm URIs we support.
1530
1530
  //
@@ -1722,7 +1722,7 @@ function _decryptEncryptedAssertion(encAssertion, spPrivateKeyPem) {
1722
1722
  return clearBytes.toString("utf8");
1723
1723
  }
1724
1724
 
1725
- // ---- v0.10.16 — SAML SLO XMLDSig-Enveloped (HTTP-POST/SOAP) ----
1725
+ // ---- SAML SLO XMLDSig-Enveloped (HTTP-POST/SOAP) ----
1726
1726
 
1727
1727
  // PQC SignatureMethod URIs used by the embedded XMLDSig signatures.
1728
1728
  // Standard XMLDSig vocabulary classical signing URIs (W3C XMLDSig
@@ -1936,7 +1936,7 @@ function _verifyEmbeddedXmlDsig(xml, idpVerifyKey, idpVerifyAlg, expectedRootLoc
1936
1936
  }
1937
1937
  }
1938
1938
 
1939
- // ---- v0.10.16 SAML SLO signature-alg dispatch ----
1939
+ // ---- SAML SLO signature-alg dispatch ----
1940
1940
 
1941
1941
  function _sigAlgUrn(alg) {
1942
1942
  // PQC signers — framework-private experimental URIs. The `urn:`
@@ -1015,7 +1015,7 @@ module.exports = {
1015
1015
  BUNDLE_ID_RE: BUNDLE_ID_RE,
1016
1016
  };
1017
1017
 
1018
- // ---- v0.12.7: bundleAdapterStorage ---------------------------------------
1018
+ // ---- bundleAdapterStorage ---------------------------------------
1019
1019
 
1020
1020
  /**
1021
1021
  * @primitive b.backup.bundleAdapterStorage
@@ -2257,7 +2257,7 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
2257
2257
  };
2258
2258
  };
2259
2259
 
2260
- // ---- v0.12.13: objectStoreAdapter ----------------------------------------
2260
+ // ---- objectStoreAdapter ----------------------------------------
2261
2261
 
2262
2262
  /**
2263
2263
  * @primitive b.backup.bundleAdapterStorage.objectStoreAdapter
@@ -2475,7 +2475,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
2475
2475
  };
2476
2476
  };
2477
2477
 
2478
- // ---- v0.12.8: migrate ----------------------------------------------------
2478
+ // ---- migrate ----------------------------------------------------
2479
2479
 
2480
2480
  /**
2481
2481
  * @primitive b.backup.migrate
@@ -538,8 +538,8 @@ function deployerChecklist(assessment) {
538
538
  }
539
539
 
540
540
  /**
541
- * @primitive b.complianceAiAct.fundamentalRightsImpactAssessment
542
- * @signature b.complianceAiAct.fundamentalRightsImpactAssessment(opts)
541
+ * @primitive b.compliance.aiAct.fundamentalRightsImpactAssessment
542
+ * @signature b.compliance.aiAct.fundamentalRightsImpactAssessment(opts)
543
543
  * @since 0.8.77
544
544
  *
545
545
  * EU AI Act Article 27 — Fundamental Rights Impact Assessment (FRIA).
@@ -569,7 +569,7 @@ function deployerChecklist(assessment) {
569
569
  * }
570
570
  *
571
571
  * @example
572
- * var fria = b.complianceAiAct.fundamentalRightsImpactAssessment({
572
+ * var fria = b.compliance.aiAct.fundamentalRightsImpactAssessment({
573
573
  * systemId: "credit-scoring-v3",
574
574
  * deploymentContext: { purpose: "loan approval", sector: "financial",
575
575
  * geography: "EU", scale: "1M decisions/year" },
@@ -603,13 +603,13 @@ function fundamentalRightsImpactAssessment(opts) {
603
603
  notificationStatus: "operator-must-notify",
604
604
  note: "Notify national market-surveillance authority before first use (Art 27(3))",
605
605
  auditHook: "b.audit emission action='aiact.fria.completed' recommended",
606
- annexIVReference: "see b.complianceAiAct.annexIVScaffold for technical documentation",
606
+ annexIVReference: "see b.compliance.aiAct.annexIVScaffold for technical documentation",
607
607
  };
608
608
  }
609
609
 
610
610
  /**
611
- * @primitive b.complianceAiAct.gpai.trainingDataSummary
612
- * @signature b.complianceAiAct.gpai.trainingDataSummary(opts)
611
+ * @primitive b.compliance.aiAct.gpai.trainingDataSummary
612
+ * @signature b.compliance.aiAct.gpai.trainingDataSummary(opts)
613
613
  * @since 0.8.77
614
614
  *
615
615
  * EU AI Act Article 53(1)(d) — GPAI training-data summary template
@@ -634,7 +634,7 @@ function fundamentalRightsImpactAssessment(opts) {
634
634
  * contentProvenance: object, // { synthIdEmbed, c2paManifestEmbed, watermarkProvider }
635
635
  *
636
636
  * @example
637
- * var summary = b.complianceAiAct.gpai.trainingDataSummary({
637
+ * var summary = b.compliance.aiAct.gpai.trainingDataSummary({
638
638
  * modelId: "acme-llm-7b",
639
639
  * modelVersion: "1.0",
640
640
  * provider: { name: "Acme AI", address: "1 St", contact: "ai@acme.example" },
@@ -107,12 +107,12 @@ var KNOWN_POSTURES = Object.freeze([
107
107
  "bsi-c5", // Germany BSI C5
108
108
  "ens-es", // Spain Esquema Nacional de Seguridad
109
109
  "uk-g-cloud", // UK G-Cloud
110
- // ---- v0.8.70 expansion — 2026 effective deadlines ----
110
+ // ---- 2026 effective deadlines ----
111
111
  "modpa", // Maryland Online Data Privacy Act (effective 2025-10-01) — strict data-min
112
112
  "nydfs-500", // NYDFS 23 NYCRR 500 Amendment 2 — financial cybersecurity (multi-factor + asset inventory + governance)
113
113
  "hipaa-2026", // HHS HIPAA Security Rule 2026-Q4 final — extends hipaa with mandatory MFA + asset inventory + 72h restoration testing
114
114
  "quebec-25", // Quebec Law 25 final phase (effective 2026-09-22) — DPIA + automated-decision opt-out
115
- // ---- v0.8.77 expansion — US state consumer-privacy postures ----
115
+ // ---- US state consumer-privacy postures ----
116
116
  // Each posture carries per-state cure-period, profiling opt-out
117
117
  // and minor-consent metadata via b.dsr.stateRules(state). The
118
118
  // generic DSR primitive (b.dsr.submit) covers ~80% of the surface;
@@ -139,7 +139,7 @@ var KNOWN_POSTURES = Object.freeze([
139
139
  "ct-sb3", // Connecticut SB 3 Consumer Health Data
140
140
  "tx-cubi", // Texas Capture or Use of Biometric Identifier
141
141
  "fl-fdbr", // Florida Digital Bill of Rights (SB 262, effective 2024-07-01) — narrow scope ($1B+ revenue threshold)
142
- // ---- v0.8.81 expansion — AI-governance postures ----
142
+ // ---- AI-governance postures ----
143
143
  // State + sectoral AI regulations crystallizing through 2026. Each
144
144
  // posture is a flag that operators pin alongside their base
145
145
  // privacy/sectoral posture; the floors enforce audit-chain signing
@@ -153,20 +153,20 @@ var KNOWN_POSTURES = Object.freeze([
153
153
  "ca-tfaia", // California SB 53 — Transparency in Frontier AI Act (effective 2026-01-01)
154
154
  "kr-ai-basic", // South Korea AI Basic Act (effective 2026-01-22)
155
155
  "cn-ai-label", // China Measures for Labelling of AI-Generated Content (effective 2025-09-01)
156
- // ---- v0.8.81 expansion — AI management cross-walks ----
156
+ // ---- AI management cross-walks ----
157
157
  "iso-42001", // ISO/IEC 42001:2023 — AI Management System
158
158
  "iso-23894", // ISO/IEC 23894:2023 — AI Risk Management Guidance
159
- // ---- v0.8.81 expansion — content-credentials posture flags ----
159
+ // ---- content-credentials posture flags ----
160
160
  "ca-sb942", // California SB-942 (Cal. Bus. & Prof. Code §22757) gen-AI disclosure (effective 2026-08-02) // regulatory identifier + date, not bytes
161
161
  "ca-ab853", // California AB-853 platform-side gen-AI detection (effective 2026-08-02) // regulatory identifier + date, not bytes
162
- // ---- v0.8.81 expansion — substrate-to-posture cleanup ----
162
+ // ---- substrate-to-posture cleanup ----
163
163
  "eaa", // EU Accessibility Act / Directive (EU) 2019/882 (effective 2025-06-28)
164
164
  "wcag-2-2", // W3C Web Content Accessibility Guidelines 2.2 (Oct 2023 Recommendation)
165
165
  "eu-data-act", // EU Data Act / Regulation (EU) 2023/2854 (effective 2025-09-12)
166
166
  "hitech", // Health Information Technology for Economic and Clinical Health Act (2009)
167
167
  "ferpa", // Family Educational Rights and Privacy Act (20 U.S.C. §1232g)
168
168
  "dpdp", // India Digital Personal Data Protection Act 2023 (rules-pending; cascade tier exists)
169
- // ---- v0.8.82 expansion — privacy 2026 sweep ----
169
+ // ---- privacy 2026 sweep ----
170
170
  // US federal child / financial privacy
171
171
  "coppa", // Children's Online Privacy Protection Act (15 U.S.C. §6501)
172
172
  "coppa-2025", // COPPA 2025 Amendment (FTC final 2025-04-22; effective 2026-06-23 — biometric expansion + knowing-collection disclosure)
@@ -203,7 +203,7 @@ var KNOWN_POSTURES = Object.freeze([
203
203
  "eu-cer", // EU Critical Entities Resilience Directive (2022/2557; transposition 2024-10-17)
204
204
  "eu-cyber-sol", // EU Cyber Solidarity Act (Regulation 2025/38; effective 2025-02-04)
205
205
  "eidas-2", // eIDAS 2 / EUDI Wallet (Regulation 2024/1183; rollout 2026-2027)
206
- // ---- v0.8.86 expansion — sectoral + cybersecurity directives ----
206
+ // ---- sectoral + cybersecurity directives ----
207
207
  "cmmc-2.0", // US DoD Cybersecurity Maturity Model Certification 2.0 (effective 2025-Q1)
208
208
  "cjis-v6", // FBI Criminal Justice Information Services Security Policy v6.0 (Dec 2024)
209
209
  "iso-27001-2022", // ISO/IEC 27001:2022 — Information Security Management System
@@ -214,7 +214,7 @@ var KNOWN_POSTURES = Object.freeze([
214
214
  "nist-800-66-r2", // NIST SP 800-66 Rev 2 — HIPAA Security Rule implementation guidance // NIST publication number, not bytes
215
215
  "ehds", // EU European Health Data Space (Regulation 2025/327; phased 2027-2029)
216
216
  "circia", // US Cyber Incident Reporting for Critical Infrastructure Act (final rule pending)
217
- // ---- v0.9.6 expansion — exceptd framework-control-gap closure ----
217
+ // ---- exceptd framework-control-gap closure ----
218
218
  // Postures added to recognise every framework cited in the
219
219
  // exceptd 2026-05-11 framework-control-gaps catalog. Each posture
220
220
  // either (a) maps to a framework the operator must audit against,
@@ -248,7 +248,7 @@ var KNOWN_POSTURES = Object.freeze([
248
248
  "cwe-top-25-2024", // CWE Top 25 Most Dangerous Software Weaknesses (2024)
249
249
  "cis-controls-v8", // CIS Controls v8
250
250
  "cmmc-2.0-level-2", // CMMC 2.0 Level 2 (Advanced) — 110 NIST 800-171 Rev 2 controls // NIST pub number / level, not bytes
251
- // ---- v0.9.57 — granular CMMC level distinction ----
251
+ // ---- granular CMMC level distinction ----
252
252
  // CMMC 2.0 maturity levels carry distinct control-mapping
253
253
  // expectations: Level 1 = 15 controls (FAR 52.204-21), Level 2 =
254
254
  // 110 controls (NIST 800-171 Rev 2), Level 3 = additional NIST
@@ -257,7 +257,7 @@ var KNOWN_POSTURES = Object.freeze([
257
257
  // L1/L2/L3 postures are the recommended pin for new deployments.
258
258
  "cmmc-2.0-level-1", // CMMC 2.0 Level 1 (Foundational) — 15 FAR controls; FCI-only data // regulatory identifier, not bytes
259
259
  "cmmc-2.0-level-3", // CMMC 2.0 Level 3 (Expert) — NIST 800-172 enhanced controls atop L2 // regulatory identifier, not bytes
260
- // ---- v0.12.1 — promote POSTURE_DEFAULTS-only entries into the
260
+ // ---- promote POSTURE_DEFAULTS-only entries into the
261
261
  // canonical KNOWN_POSTURES surface so operators can actually
262
262
  // `b.compliance.set(...)` them. Each entry had cascade
263
263
  // configuration wired but couldn't be pinned because set()'s
@@ -757,7 +757,7 @@ var REGIME_MAP = Object.freeze({
757
757
  "ct-sb3": { name: "Connecticut SB 3 Consumer Health Data", citation: "Conn. P.A. 23-56 (effective 2023-07-01)", jurisdiction: "US-CT", domain: "health" },
758
758
  "tx-cubi": { name: "Texas Capture or Use of Biometric Identifier", citation: "Tex. Bus. & Com. Code §503.001 (effective 2009-09-01)", jurisdiction: "US-TX", domain: "biometric" },
759
759
  "fl-fdbr": { name: "Florida Digital Bill of Rights", citation: "Fla. Stat. §501.701 et seq. SB 262 (effective 2024-07-01)", jurisdiction: "US-FL", domain: "privacy" },
760
- // ---- v0.8.81 — AI governance ----
760
+ // ---- AI governance ----
761
761
  "co-ai": { name: "Colorado AI Act", citation: "C.R.S. §6-1-1701 et seq. SB24-205 (postponed to 2026-06-30; enforcement stayed)", jurisdiction: "US-CO", domain: "ai-governance" },
762
762
  "il-hb3773": { name: "Illinois HB 3773 — AI in Employment", citation: "775 ILCS 5 IHRA AI amendment (effective 2026-01-01)", jurisdiction: "US-IL", domain: "ai-governance" },
763
763
  "tx-traiga": { name: "Texas Responsible AI Governance Act", citation: "Tex. Bus. & Com. Code Ch. 552 HB 149 (effective 2026-01-01)", jurisdiction: "US-TX", domain: "ai-governance" },
@@ -766,20 +766,20 @@ var REGIME_MAP = Object.freeze({
766
766
  "ca-tfaia": { name: "California Transparency in Frontier AI Act", citation: "Cal. Bus. & Prof. Code §22757.10 et seq. SB 53 (effective 2026-01-01)", jurisdiction: "US-CA", domain: "ai-governance" },
767
767
  "kr-ai-basic": { name: "South Korea AI Basic Act", citation: "Framework Act on Development of AI (effective 2026-01-22)", jurisdiction: "KR", domain: "ai-governance" },
768
768
  "cn-ai-label": { name: "China — Measures for Labelling AI-Generated Content", citation: "CAC + MIIT + Ministry of Public Security + NRTA Order (effective 2025-09-01)", jurisdiction: "CN", domain: "ai-governance" },
769
- // ---- v0.8.81 — AI management cross-walks ----
769
+ // ---- AI management cross-walks ----
770
770
  "iso-42001": { name: "ISO/IEC 42001 — AI Management System", citation: "ISO/IEC 42001:2023", jurisdiction: "international", domain: "ai-governance" },
771
771
  "iso-23894": { name: "ISO/IEC 23894 — AI Risk Management", citation: "ISO/IEC 23894:2023", jurisdiction: "international", domain: "ai-governance" },
772
- // ---- v0.8.81 — content-credentials posture flags ----
772
+ // ---- content-credentials posture flags ----
773
773
  "ca-sb942": { name: "California Gen-AI Provenance Disclosure", citation: "Cal. Bus. & Prof. Code §22757 SB-942 (effective 2026-08-02)", jurisdiction: "US-CA", domain: "content-credentials" },
774
774
  "ca-ab853": { name: "California Platform Gen-AI Detection", citation: "Cal. Bus. & Prof. Code §22757 AB-853 (effective 2026-08-02)", jurisdiction: "US-CA", domain: "content-credentials" },
775
- // ---- v0.8.81 — substrate-to-posture cleanup ----
775
+ // ---- substrate-to-posture cleanup ----
776
776
  "eaa": { name: "EU Accessibility Act", citation: "Directive (EU) 2019/882 (effective 2025-06-28)", jurisdiction: "EU", domain: "accessibility" },
777
777
  "wcag-2-2": { name: "W3C Web Content Accessibility Guidelines 2.2", citation: "W3C Recommendation (Oct 2023)", jurisdiction: "international", domain: "accessibility" },
778
778
  "eu-data-act": { name: "EU Data Act", citation: "Regulation (EU) 2023/2854 (effective 2025-09-12)", jurisdiction: "EU", domain: "data-sharing" },
779
779
  "hitech": { name: "Health Information Technology for Economic and Clinical Health Act", citation: "Pub. L. 111-5, Title XIII, Subtitle D (2009)", jurisdiction: "US", domain: "health" },
780
780
  "ferpa": { name: "Family Educational Rights and Privacy Act", citation: "20 U.S.C. §1232g; 34 CFR Part 99", jurisdiction: "US", domain: "student-records" },
781
781
  "dpdp": { name: "Digital Personal Data Protection Act 2023", citation: "Act 22 of 2023 (India; rules pending)", jurisdiction: "IN", domain: "privacy" },
782
- // ---- v0.8.82 — privacy 2026 sweep ----
782
+ // ---- privacy 2026 sweep ----
783
783
  // US federal
784
784
  "coppa": { name: "Children's Online Privacy Protection Act", citation: "15 U.S.C. §§6501-6506; 16 CFR Part 312 (effective 2000-04-21)", jurisdiction: "US", domain: "child-privacy" },
785
785
  "coppa-2025": { name: "COPPA 2025 Amendment", citation: "FTC final rule (2025-04-22; effective 2026-06-23) — biometric expansion + knowing-collection-13-and-under disclosure", jurisdiction: "US", domain: "child-privacy" },
@@ -815,7 +815,7 @@ var REGIME_MAP = Object.freeze({
815
815
  "eu-cer": { name: "EU Critical Entities Resilience Directive", citation: "Directive (EU) 2022/2557 (transposition 2024-10-17)", jurisdiction: "EU", domain: "cybersecurity" },
816
816
  "eu-cyber-sol": { name: "EU Cyber Solidarity Act", citation: "Regulation (EU) 2025/38 (effective 2025-02-04)", jurisdiction: "EU", domain: "cybersecurity" },
817
817
  "eidas-2": { name: "eIDAS 2 / EUDI Wallet", citation: "Regulation (EU) 2024/1183 (rollout 2026-2027)", jurisdiction: "EU", domain: "identity" },
818
- // ---- v0.8.86 — sectoral + cybersecurity directives ----
818
+ // ---- sectoral + cybersecurity directives ----
819
819
  "cmmc-2.0": { name: "Cybersecurity Maturity Model Certification 2.0", citation: "32 CFR Part 170 (DFARS rule effective 2025-Q1)", jurisdiction: "US", domain: "cybersecurity" },
820
820
  "cjis-v6": { name: "FBI CJIS Security Policy v6.0", citation: "CJIS Security Policy v6.0 (effective 2024-12)", jurisdiction: "US", domain: "law-enforcement" },
821
821
  "iso-27001-2022": { name: "ISO/IEC 27001:2022 Information Security Management System", citation: "ISO/IEC 27001:2022", jurisdiction: "international", domain: "cybersecurity" },
@@ -826,7 +826,7 @@ var REGIME_MAP = Object.freeze({
826
826
  "nist-800-66-r2": { name: "NIST SP 800-66 Rev 2 — HIPAA Security Rule Guidance", citation: "NIST SP 800-66 Rev 2 (Feb 2024)", jurisdiction: "US", domain: "health" },
827
827
  "ehds": { name: "European Health Data Space", citation: "Regulation (EU) 2025/327 (phased 2027-2029)", jurisdiction: "EU", domain: "health" },
828
828
  "circia": { name: "Cyber Incident Reporting for Critical Infrastructure Act", citation: "6 U.S.C. §681 et seq. (final rule pending)", jurisdiction: "US", domain: "cybersecurity" },
829
- // ---- v0.12.1 — REGIME_MAP backfill for KNOWN_POSTURES without
829
+ // ---- REGIME_MAP backfill for KNOWN_POSTURES without
830
830
  // describe() coverage. Each entry resolves `b.compliance.describe
831
831
  // (posture)` → { name, citation, jurisdiction, domain } so admin
832
832
  // UI / generated audit reports rendering "running under <name>
@@ -870,7 +870,7 @@ var REGIME_MAP = Object.freeze({
870
870
  "bsi-c5": { name: "Germany BSI C5 — Cloud Computing Compliance Catalogue", citation: "BSI Cloud Computing Compliance Criteria Catalogue (C5:2020)", jurisdiction: "DE", domain: "cybersecurity" },
871
871
  "ens-es": { name: "Spain Esquema Nacional de Seguridad", citation: "Real Decreto 311/2022", jurisdiction: "ES", domain: "cybersecurity" },
872
872
  "uk-g-cloud": { name: "UK G-Cloud Framework", citation: "UK Crown Commercial Service G-Cloud 14", jurisdiction: "UK", domain: "cybersecurity" },
873
- // ---- v0.9.6 expansion REGIME_MAP backfill (cybersecurity / AI / supply-chain frameworks) ----
873
+ // ---- REGIME_MAP backfill (cybersecurity / AI / supply-chain frameworks) ----
874
874
  "nist-800-53": { name: "NIST SP 800-53 Rev 5 — Security & Privacy Controls", citation: "NIST SP 800-53 Rev 5", jurisdiction: "US", domain: "cybersecurity" },
875
875
  "nist-ai-rmf-1.0": { name: "NIST AI Risk Management Framework 1.0", citation: "NIST AI 100-1 (Jan 2023)", jurisdiction: "US", domain: "ai" },
876
876
  "iso-42001-2023": { name: "ISO/IEC 42001:2023 — AI Management System", citation: "ISO/IEC 42001:2023", jurisdiction: "international", domain: "ai" },
@@ -1176,7 +1176,7 @@ var POSTURE_DEFAULTS = Object.freeze({
1176
1176
  "nist-800-66-r2": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1177
1177
  "ehds": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1178
1178
  "circia": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1179
- // ---- v0.9.6 — exceptd framework-control-gap closure cascade ----
1179
+ // ---- exceptd framework-control-gap closure cascade ----
1180
1180
  "nist-800-53": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1181
1181
  // NIST AI-RMF MANAGE.4.3 / ISO 23894 §6.5 / ISO 42001
1182
1182
  // §A.6 require encrypted backups for AI system state (model
@@ -1242,7 +1242,7 @@ var POSTURE_DEFAULTS = Object.freeze({
1242
1242
  "cmmc-2.0-level-1": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1243
1243
  "cmmc-2.0-level-2": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1244
1244
  "cmmc-2.0-level-3": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true, fipsMode: false }),
1245
- // ---- v0.10.16 — sectoral catch-up ----
1245
+ // ---- sectoral catch-up ----
1246
1246
  // 42 CFR Part 2 — Substance Use Disorder records confidentiality
1247
1247
  // (HHS final rule 2024-04-16 aligns Part 2 with HIPAA but retains
1248
1248
  // a stricter consent floor; encrypted backups + signed audit chain
@@ -1078,7 +1078,7 @@ function dbTicketStore(opts) {
1078
1078
  };
1079
1079
  }
1080
1080
 
1081
- // ---- v0.8.77 — US state-law DSR drift registry -------------------
1081
+ // ---- US state-law DSR drift registry -------------------
1082
1082
  //
1083
1083
  // Each US state consumer-privacy law expresses the same DSR core
1084
1084
  // (access / deletion / correction / portability) but with per-state
@@ -905,7 +905,7 @@ module.exports = {
905
905
  tarEntryPolicy: tarEntryPolicy,
906
906
  };
907
907
 
908
- // ---- v0.12.7 extensions ---------------------------------------------------
908
+ // ---- extensions ---------------------------------------------------
909
909
 
910
910
  /**
911
911
  * @primitive b.guardArchive.inspect
@@ -928,7 +928,7 @@ function _audit(auditHandle, action, outcome, metadata) {
928
928
  } catch (_e) { /* drop-silent — audit failures must not crash callers */ }
929
929
  }
930
930
 
931
- // ---- v0.10.16 experimental encrypt/decrypt + WKD ----
931
+ // ---- experimental encrypt/decrypt + WKD ----
932
932
  //
933
933
  // PQC PGP encrypt/decrypt for ML-KEM-1024 recipients shipped under
934
934
  // `experimental` namespace (RFC 9580bis PKESK ML-KEM codepoints
@@ -36,12 +36,6 @@
36
36
  * parsed as `country: false`). Block + flow style;
37
37
  * literal `|` and folded `>` block scalars with chomp
38
38
  * indicators.
39
- * env — .env file loader with size cap + schema validation;
40
- * refuses to expand $VAR references; refuses to silently
41
- * overwrite existing process.env values unless explicitly
42
- * opted in. Dev-tooling — production secrets should still
43
- * come through the operator's secrets-management; this is
44
- * the local-development convenience.
45
39
  * ini — INI / .gitconfig / systemd-unit / php.ini / tox.ini parser.
46
40
  * Sections (incl. [parent.child] / [parent "child"] nesting),
47
41
  * ; or # comments (inline + leading), single + double quoting
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.8",
3
+ "version": "0.14.9",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.9",
4
+ "date": "2026-05-30",
5
+ "headline": "Corrects EU AI Act doc paths that named an uncallable namespace, plus source-comment hygiene and two new codebase checks",
6
+ "summary": "A documentation fix and internal hygiene. The `@primitive` / `@signature` / `@example` blocks for the EU AI Act fundamental-rights-impact-assessment and GPAI training-data-summary helpers advertised `b.complianceAiAct.*`, which is undefined — the callable path is `b.compliance.aiAct.*` — so an operator copying the documented call got `undefined is not a function`. The documented paths now match the real surface. Alongside that: a duplicate parser entry in a doc block is removed, version stamps embedded in section-divider comments are stripped, and two codebase checks are added — one that fails the build when a `@primitive` block documents a wholly-unresolvable namespace (the gap that hid the AI Act paths), and one that flags a version stamp left inside a section divider. No exported API, error code, wire format, or runtime behaviour changes.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "EU AI Act helper documentation named an uncallable path",
13
+ "body": "`b.compliance.aiAct.fundamentalRightsImpactAssessment` and `b.compliance.aiAct.gpai.trainingDataSummary` were documented as `b.complianceAiAct.*` in their `@primitive` / `@signature` / `@example` blocks (and one returned reference string). `b.complianceAiAct` is undefined, so the documented call failed; the documented paths now match the callable surface."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "heading": "Changed",
19
+ "items": [
20
+ {
21
+ "title": "Source-comment hygiene",
22
+ "body": "Removed a duplicate `env` entry from the parsers `@module` doc block, and stripped internal version stamps (`vX.Y.Z`) from `// ---- ... ----` section-divider comments across several files, keeping the descriptive label. Comment-only; no behaviour change."
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "heading": "Detectors",
28
+ "items": [
29
+ {
30
+ "title": "`@primitive` reachability covers wrong-namespace paths",
31
+ "body": "The reachability check previously only flagged a missing leaf on a resolved namespace; a `@primitive` whose entire dotted prefix is unresolvable (the shape that hid the AI Act doc paths) was silently skipped. It now walks each prefix segment and fails the build on any unresolvable one, while preserving the factory-instance-shorthand exemption."
32
+ },
33
+ {
34
+ "title": "Version-stamp-in-divider check",
35
+ "body": "A new check flags a version stamp (`vX.Y.Z`) left immediately after a section divider's dashes (`// ---- vX.Y.Z ...`) — internal release vocabulary that does not belong in shipped source comments — without matching legitimate `@since` tags or prose version references."
36
+ }
37
+ ]
38
+ }
39
+ ]
40
+ }
@@ -10801,12 +10801,46 @@ function testPrimitiveReachability() {
10801
10801
  return cur;
10802
10802
  }
10803
10803
 
10804
+ // Walk the dotted prefix (every segment except the leaf) so a break at
10805
+ // ANY segment — including a wholesale-wrong namespace whose parent
10806
+ // resolves to undefined — is surfaced rather than silently skipped. A
10807
+ // function OR a `.create`-bearing object anywhere in the chain is a
10808
+ // factory-instance shorthand and stays exempt.
10809
+ function walkPrefix(name, surface) {
10810
+ var parts = name.split(".");
10811
+ var cur = surface;
10812
+ for (var i = 1; i < parts.length - 1; i += 1) {
10813
+ // A missing prefix segment is a wrong-namespace doc path even when
10814
+ // `cur` is itself a factory (has `.create`): a namespace that ALSO
10815
+ // has real static children (e.g. b.mail.create + b.mail.bimi /
10816
+ // b.mail.rbl) is used statically, so an undefined child is a typo,
10817
+ // not a factory-instance method. The factory-shorthand exemption is
10818
+ // therefore applied ONLY at the leaf parent (the trailing function
10819
+ // check below + the caller's `.create` check) — never at an
10820
+ // intermediate segment, which would mask the typo.
10821
+ if (cur == null || typeof cur[parts[i]] === "undefined") {
10822
+ return { brokenName: parts.slice(0, i + 1).join(".") };
10823
+ }
10824
+ cur = cur[parts[i]];
10825
+ }
10826
+ if (typeof cur === "function") return { factory: true };
10827
+ return { parent: cur, parentName: parts.slice(0, -1).join(".") };
10828
+ }
10829
+
10804
10830
  // @primitive paths that legitimately don't resolve as a flat member
10805
10831
  // (documented elsewhere / intentional). Keyed by dotted name.
10806
10832
  var REACHABILITY_ALLOWLIST = {
10807
10833
  // (none — every surfaced gap is fixed in-tree)
10808
10834
  };
10809
10835
 
10836
+ // Self-test (locks the fix): a missing intermediate segment under a
10837
+ // mixed factory+static namespace must be flagged, never masked by the
10838
+ // factory-shorthand exemption.
10839
+ var _rMock = { mail: { create: function () {}, bimi: {} } };
10840
+ var _rProbe = walkPrefix("b.mail.bmi.recordShape", _rMock);
10841
+ check("primitive-reachability: typo under a mixed factory namespace is flagged",
10842
+ _rProbe.brokenName === "b.mail.bmi");
10843
+
10810
10844
  var libFiles = _libFiles();
10811
10845
  var unreachable = [];
10812
10846
  for (var i = 0; i < libFiles.length; i += 1) {
@@ -10818,9 +10852,24 @@ function testPrimitiveReachability() {
10818
10852
  var name = m[1];
10819
10853
  if (REACHABILITY_ALLOWLIST[name]) continue;
10820
10854
  if (typeof resolve(name) !== "undefined") continue;
10821
- var parts = name.split(".");
10822
- var parentName = parts.slice(0, -1).join(".");
10823
- var parent = resolve(parentName);
10855
+ var w = walkPrefix(name, bSurface);
10856
+ // Factory-instance shorthands (b.X.create() → instance method) skip.
10857
+ if (w.factory) continue;
10858
+ if (w.brokenName) {
10859
+ // The whole dotted namespace prefix is unresolvable — the parent
10860
+ // resolved to undefined, so the flat-namespace check below never
10861
+ // fired and the doc-lie was silently skipped. Flag it.
10862
+ unreachable.push({
10863
+ file: rel,
10864
+ line: 1,
10865
+ content: "@primitive " + name + " documents a b.* path that does not resolve (the namespace `" +
10866
+ w.brokenName + "` is undefined — no prefix segment resolves on the public surface). " +
10867
+ "Correct the @primitive/@signature namespace, or wire the namespace into the surface.",
10868
+ });
10869
+ continue;
10870
+ }
10871
+ var parent = w.parent;
10872
+ var parentName = w.parentName;
10824
10873
  // Flag only flat namespaces (object parent without a `create`
10825
10874
  // factory). Factory-instance shorthands are skipped.
10826
10875
  if (parent && typeof parent === "object" && typeof parent.create !== "function") {
@@ -10913,6 +10962,7 @@ function testNoInternalNarrativeComments() {
10913
10962
  { re: /\b[Aa]udit\s+\d{4}-\d{2}-\d{2}/, what: "dated audit/decision residue" },
10914
10963
  { re: /\bReported\s+\d{4}-\d{2}-\d{2}/, what: "dated report residue" },
10915
10964
  { re: /\bCore Rule\s+§\d/, what: "internal CLAUDE.md rule-number citation" },
10965
+ { re: /----\s*v\d+\.\d+\.\d+/, what: "version stamp in a section-divider comment" },
10916
10966
  ];
10917
10967
  var files = _libFiles();
10918
10968
  var bad = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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": {