@blamejs/blamejs-shop 0.2.15 → 0.2.18

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/README.md +2 -2
  3. package/lib/admin.js +81 -3
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
package/lib/storefront.js CHANGED
@@ -4942,6 +4942,10 @@ var ACCOUNT_DASH_PAGE =
4942
4942
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
4943
4943
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
4944
4944
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
4945
+ // begin: profile + passkey management actions
4946
+ " <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
4947
+ " <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
4948
+ // end: profile + passkey management actions
4945
4949
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
4946
4950
  " </div>\n" +
4947
4951
  " </header>\n" +
@@ -5046,6 +5050,200 @@ function renderAccount(opts) {
5046
5050
  });
5047
5051
  }
5048
5052
 
5053
+ // ---- passkey management ------------------------------------------------
5054
+ //
5055
+ // The signed-in customer's enrolled WebAuthn credentials, each with a
5056
+ // confirm-gated revoke, plus an "add another passkey" flow that reuses
5057
+ // the same begin/finish ceremony the registration page drives (here
5058
+ // bound to the AUTHED customer, so no email form is involved). Email is
5059
+ // stored hash-only and a passkey is the account's sign-in method, so the
5060
+ // list never leaks the address and the last-credential guard (enforced in
5061
+ // the route, surfaced here) keeps a customer from locking themselves out.
5062
+
5063
+ // Map the WebAuthn transports hint to a short human label for the list.
5064
+ function _transportLabel(t) {
5065
+ if (t === "internal") return "This device";
5066
+ if (t === "hybrid") return "Phone / nearby device";
5067
+ if (t === "usb") return "Security key (USB)";
5068
+ if (t === "nfc") return "Security key (NFC)";
5069
+ if (t === "ble") return "Security key (Bluetooth)";
5070
+ return t;
5071
+ }
5072
+
5073
+ function renderPasskeys(opts) {
5074
+ opts = opts || {};
5075
+ var esc = b.template.escapeHtml;
5076
+ var list = opts.passkeys || [];
5077
+ // Only offer per-credential revoke when removing it still leaves the
5078
+ // customer a way back in — another passkey, OR a linked OAuth identity.
5079
+ // When this is the last credential and there's no federated fallback,
5080
+ // the revoke control is replaced by a disabled note so the customer
5081
+ // can't lock themselves out. The route enforces the same rule server-
5082
+ // side; this is the matching display.
5083
+ var canRevokeAny = list.length > 1 || (opts.has_oauth === true && list.length >= 1);
5084
+ var rowsHtml = "";
5085
+ for (var i = 0; i < list.length; i += 1) {
5086
+ var p = list[i];
5087
+ var transports = (p.transports ? String(p.transports).split(",") : [])
5088
+ .filter(Boolean)
5089
+ .map(function (t) { return "<span class=\"passkey-card__transport\">" + esc(_transportLabel(t)) + "</span>"; })
5090
+ .join("");
5091
+ var added = "";
5092
+ if (p.created_at) {
5093
+ added = "<p class=\"passkey-card__meta\">Added " + esc(new Date(p.created_at).toISOString().slice(0, 10)) + "</p>";
5094
+ }
5095
+ // The credential handle is opaque; show a short fingerprint so the
5096
+ // customer can tell two devices apart without exposing the full key.
5097
+ var fingerprint = p.credential_id ? esc(String(p.credential_id).slice(0, 12)) : esc(String(p.id).slice(0, 12));
5098
+ var actionCell = canRevokeAny
5099
+ ? "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/passkeys/" + esc(String(p.id)) + "/remove\">Revoke</a>"
5100
+ : "<span class=\"passkey-card__last\" title=\"This is your only way to sign in\">Only sign-in method</span>";
5101
+ rowsHtml +=
5102
+ "<li class=\"passkey-card\">" +
5103
+ "<div class=\"passkey-card__body\">" +
5104
+ "<p class=\"passkey-card__id\"><code>" + fingerprint + "…</code></p>" +
5105
+ (transports ? "<p class=\"passkey-card__transports\">" + transports + "</p>" : "") +
5106
+ added +
5107
+ "</div>" +
5108
+ "<div class=\"passkey-card__actions\">" + actionCell + "</div>" +
5109
+ "</li>";
5110
+ }
5111
+ var listHtml = rowsHtml
5112
+ ? "<ul class=\"passkey-list\">" + rowsHtml + "</ul>"
5113
+ : "<div class=\"account-empty\">" +
5114
+ "<p class=\"account-empty__lede\">No passkeys enrolled. Add one below so you can sign in from this device.</p>" +
5115
+ "</div>";
5116
+ // Success / notice banners, driven by the ?ok=<kind> PRG redirect.
5117
+ var notice = opts.notice
5118
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
5119
+ : "";
5120
+ var success = opts.success
5121
+ ? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span></div>"
5122
+ : "";
5123
+ // The "add another" control is an island-driven button (CSP forbids
5124
+ // inline script): it runs navigator.credentials.create against the
5125
+ // authed begin/finish endpoints. With JS off the button does nothing,
5126
+ // so it's framed as an enhancement, not the only path (a JS-off
5127
+ // customer who needs another device can re-register through the normal
5128
+ // flow — same credential lands on the same account by email).
5129
+ var addBlock =
5130
+ "<section class=\"passkey-add\">" +
5131
+ "<h2 class=\"account-addresses__form-title\">Add another passkey</h2>" +
5132
+ "<p class=\"passkey-add__lede\">Enroll a passkey on another device or security key so you can sign in from more than one place.</p>" +
5133
+ "<div class=\"form-actions\"><button type=\"button\" id=\"passkey-add-btn\" class=\"btn-primary\">Add a passkey</button></div>" +
5134
+ "<p id=\"passkey-add-message\" class=\"auth-form__message\"></p>" +
5135
+ "RAW_PASSKEY_ADD_SCRIPT" +
5136
+ "</section>";
5137
+ var body =
5138
+ "<section class=\"account-passkeys\">" +
5139
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5140
+ "<li><a href=\"/account\">Account</a></li>" +
5141
+ "<li aria-current=\"page\">Passkeys</li>" +
5142
+ "</ol></nav>" +
5143
+ "<h1 class=\"account-addresses__title\">Passkeys</h1>" +
5144
+ "<p class=\"section-head__lede\">Devices that can sign in to your account. Revoke any you no longer use.</p>" +
5145
+ success +
5146
+ notice +
5147
+ listHtml +
5148
+ addBlock +
5149
+ "</section>";
5150
+ body = body.replace("RAW_PASSKEY_ADD_SCRIPT", _islandScript("passkey-add.js"));
5151
+ return _wrap({
5152
+ title: "Passkeys",
5153
+ shop_name: opts.shop_name || "blamejs.shop",
5154
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5155
+ theme_css: opts.theme_css,
5156
+ body: body,
5157
+ });
5158
+ }
5159
+
5160
+ // Server-rendered confirm step for revoking a passkey — CSP forbids an
5161
+ // inline confirm() dialog, so the destructive action is gated behind a
5162
+ // second page whose POST actually revokes. Mirrors renderAddressRemoveConfirm.
5163
+ function renderPasskeyRemoveConfirm(opts) {
5164
+ opts = opts || {};
5165
+ var esc = b.template.escapeHtml;
5166
+ var p = opts.passkey || {};
5167
+ var fingerprint = p.credential_id ? esc(String(p.credential_id).slice(0, 12)) : esc(String(p.id).slice(0, 12));
5168
+ var added = p.created_at
5169
+ ? "<p class=\"passkey-card__meta\">Added " + esc(new Date(p.created_at).toISOString().slice(0, 10)) + "</p>"
5170
+ : "";
5171
+ var body =
5172
+ "<section class=\"account-confirm\">" +
5173
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5174
+ "<li><a href=\"/account\">Account</a></li>" +
5175
+ "<li><a href=\"/account/passkeys\">Passkeys</a></li>" +
5176
+ "<li aria-current=\"page\">Revoke passkey</li>" +
5177
+ "</ol></nav>" +
5178
+ "<h1 class=\"account-confirm__title\">Revoke this passkey?</h1>" +
5179
+ "<p class=\"passkey-card__id\"><code>" + fingerprint + "…</code></p>" +
5180
+ added +
5181
+ "<p class=\"account-confirm__lede\">The device holding this passkey will no longer be able to sign in. " +
5182
+ "Make sure you still have another way into your account before revoking.</p>" +
5183
+ "<div class=\"account-confirm__actions\">" +
5184
+ "<form method=\"post\" action=\"/account/passkeys/" + esc(String(p.id)) + "/revoke\">" +
5185
+ "<button type=\"submit\" class=\"btn-primary\">Revoke passkey</button>" +
5186
+ "</form>" +
5187
+ "<a class=\"btn-ghost\" href=\"/account/passkeys\">Cancel</a>" +
5188
+ "</div>" +
5189
+ "</section>";
5190
+ return _wrap({
5191
+ title: "Revoke passkey",
5192
+ shop_name: opts.shop_name || "blamejs.shop",
5193
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5194
+ theme_css: opts.theme_css,
5195
+ body: body,
5196
+ });
5197
+ }
5198
+
5199
+ // ---- profile edit ------------------------------------------------------
5200
+ //
5201
+ // Display-name edit for the signed-in customer. Email is stored hash-only
5202
+ // and is the OAuth account-linking key, so it's shown read-only (masked)
5203
+ // and cannot be changed here — the primitive refuses an email patch until
5204
+ // a verification ceremony exists. The form is a plain server-rendered
5205
+ // POST with a PRG `?ok=updated` success notice, matching the addresses
5206
+ // pattern.
5207
+ function renderProfile(opts) {
5208
+ opts = opts || {};
5209
+ var esc = b.template.escapeHtml;
5210
+ var customer = opts.customer || {};
5211
+ var notice = opts.notice
5212
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
5213
+ : "";
5214
+ var success = opts.success
5215
+ ? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span></div>"
5216
+ : "";
5217
+ var displayValue = esc(String(customer.display_name == null ? "" : customer.display_name));
5218
+ var body =
5219
+ "<section class=\"account-profile\">" +
5220
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5221
+ "<li><a href=\"/account\">Account</a></li>" +
5222
+ "<li aria-current=\"page\">Profile</li>" +
5223
+ "</ol></nav>" +
5224
+ "<h1 class=\"account-addresses__title\">Edit profile</h1>" +
5225
+ success +
5226
+ notice +
5227
+ "<form method=\"post\" action=\"/account/profile\" class=\"form-stack\">" +
5228
+ "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span>" +
5229
+ "<input type=\"text\" name=\"display_name\" maxlength=\"128\" required autocomplete=\"name\" value=\"" + displayValue + "\"></label></div>" +
5230
+ "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
5231
+ "<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
5232
+ "<p id=\"email-note\" class=\"form-field__hint\">Your email address is never stored in readable form, so it can't be changed or shown here. " +
5233
+ "Sign in with the address you registered.</p>" +
5234
+ "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
5235
+ "<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
5236
+ "</form>" +
5237
+ "</section>";
5238
+ return _wrap({
5239
+ title: "Edit profile",
5240
+ shop_name: opts.shop_name || "blamejs.shop",
5241
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5242
+ theme_css: opts.theme_css,
5243
+ body: body,
5244
+ });
5245
+ }
5246
+
5049
5247
  // ---- customer survey page ----------------------------------------------
5050
5248
 
5051
5249
  // Render one survey question as a fieldset. Rating → a 0/1..max radio
@@ -7551,6 +7749,267 @@ function mount(router, deps) {
7551
7749
  return res.end ? res.end() : res.send("");
7552
7750
  });
7553
7751
 
7752
+ // ---- passkey self-management + profile edit ----------------------
7753
+ //
7754
+ // The signed-in customer manages their own credentials: list enrolled
7755
+ // passkeys, revoke one (confirm-gated), or enroll another device. The
7756
+ // revoke is guarded so a customer can't strip away their last sign-in
7757
+ // method when they have no federated (OAuth) fallback — that would lock
7758
+ // them out. The add flow reuses the WebAuthn begin/finish ceremony the
7759
+ // registration page drives, but bound to the ALREADY-authed customer
7760
+ // (no email form), so a new credential always lands on the right
7761
+ // account.
7762
+ function _accountAuth(req, res) {
7763
+ var auth;
7764
+ try { auth = _currentCustomer(req); }
7765
+ catch (e) {
7766
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
7767
+ throw e;
7768
+ }
7769
+ if (!auth) {
7770
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7771
+ res.end ? res.end() : res.send("");
7772
+ return null;
7773
+ }
7774
+ return auth;
7775
+ }
7776
+
7777
+ // Does this customer have a federated (OAuth) sign-in to fall back on?
7778
+ // Used as the "is it safe to revoke the last passkey" gate. Composes
7779
+ // the existing batched sign-in-methods aggregate (no bespoke query).
7780
+ async function _hasOAuthFallback(customerId) {
7781
+ var methods = await deps.customers.signInMethodsByCustomer([customerId]);
7782
+ var providers = methods.oauth[customerId];
7783
+ return Array.isArray(providers) && providers.length > 0;
7784
+ }
7785
+
7786
+ function _passkeySuccessCopy(kind) {
7787
+ if (kind === "revoked") return "Passkey revoked.";
7788
+ if (kind === "added") return "Passkey added.";
7789
+ return null;
7790
+ }
7791
+
7792
+ async function _renderPasskeysPage(req, res, auth, notice, code) {
7793
+ var pks = await deps.customers.listPasskeys(auth.customer_id);
7794
+ var hasOAuth = await _hasOAuthFallback(auth.customer_id);
7795
+ var cartCount = await _cartCountForReq(req);
7796
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7797
+ var okKind = url ? url.searchParams.get("ok") : null;
7798
+ _send(res, code || 200, renderPasskeys({
7799
+ passkeys: pks,
7800
+ has_oauth: hasOAuth,
7801
+ notice: notice || null,
7802
+ success: _passkeySuccessCopy(okKind),
7803
+ shop_name: shopName,
7804
+ cart_count: cartCount,
7805
+ }));
7806
+ }
7807
+
7808
+ // Resolve a passkey by path id AND confirm it belongs to the authed
7809
+ // customer. A non-UUID segment or a credential owned by someone else
7810
+ // is a 404 — never a cross-customer reveal.
7811
+ async function _ownedPasskey(req, res, auth) {
7812
+ var pks;
7813
+ try { pks = await deps.customers.listPasskeys(auth.customer_id); }
7814
+ catch (e) { throw e; }
7815
+ var id = req.params && req.params.id;
7816
+ for (var i = 0; i < pks.length; i += 1) {
7817
+ if (pks[i].id === id) return pks[i];
7818
+ }
7819
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
7820
+ return null;
7821
+ }
7822
+
7823
+ router.get("/account/passkeys", async function (req, res) {
7824
+ var auth = _accountAuth(req, res); if (!auth) return;
7825
+ await _renderPasskeysPage(req, res, auth, null);
7826
+ });
7827
+
7828
+ // Revoke is destructive + CSP forbids confirm(), so it routes through a
7829
+ // server-rendered confirm page; the POST that actually revokes lives
7830
+ // behind it.
7831
+ router.get("/account/passkeys/:id/remove", async function (req, res) {
7832
+ var auth = _accountAuth(req, res); if (!auth) return;
7833
+ var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
7834
+ var cartCount = await _cartCountForReq(req);
7835
+ _send(res, 200, renderPasskeyRemoveConfirm({
7836
+ passkey: pk,
7837
+ shop_name: shopName,
7838
+ cart_count: cartCount,
7839
+ }));
7840
+ });
7841
+
7842
+ router.post("/account/passkeys/:id/revoke", async function (req, res) {
7843
+ var auth = _accountAuth(req, res); if (!auth) return;
7844
+ var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
7845
+ // Last-credential guard: refuse to remove the only sign-in method
7846
+ // when there's no federated fallback — surface a clear notice rather
7847
+ // than silently locking the customer out.
7848
+ var pks = await deps.customers.listPasskeys(auth.customer_id);
7849
+ if (pks.length <= 1 && !(await _hasOAuthFallback(auth.customer_id))) {
7850
+ return _renderPasskeysPage(
7851
+ req, res, auth,
7852
+ "That's your only way to sign in — add another passkey (or link a Google / Apple account) before revoking this one.",
7853
+ 409,
7854
+ );
7855
+ }
7856
+ try {
7857
+ await deps.customers.removePasskey(auth.customer_id, pk.id);
7858
+ } catch (e) {
7859
+ if (e instanceof TypeError) return _renderPasskeysPage(req, res, auth, (e && e.message) || "Could not revoke that passkey.", 400);
7860
+ throw e;
7861
+ }
7862
+ res.status(303); res.setHeader && res.setHeader("location", "/account/passkeys?ok=revoked");
7863
+ return res.end ? res.end() : res.send("");
7864
+ });
7865
+
7866
+ // Add-another-passkey ceremony — same begin/finish shape as
7867
+ // registration, but for the AUTHED customer (no email form). The
7868
+ // challenge cookie carries kind "add" + the customer id; finish gates
7869
+ // on both so an add-finish can't be replayed against a register/login
7870
+ // challenge.
7871
+ router.post("/account/passkey/add-begin", async function (req, res) {
7872
+ var auth = _accountAuth(req, res); if (!auth) return;
7873
+ try {
7874
+ var customer = await deps.customers.get(auth.customer_id);
7875
+ if (!customer) { res.status(401); return res.end ? res.end("unknown customer") : res.send("unknown customer"); }
7876
+ // Exclude already-enrolled credentials so the authenticator won't
7877
+ // create a duplicate on the same device.
7878
+ var existing = await deps.customers.listPasskeys(customer.id);
7879
+ var exclude = existing.map(function (p) {
7880
+ return {
7881
+ id: p.credential_id,
7882
+ type: "public-key",
7883
+ transports: p.transports ? p.transports.split(",") : undefined,
7884
+ };
7885
+ });
7886
+ var startOpts = await b.auth.passkey.startRegistration({
7887
+ rpName: rpName,
7888
+ rpId: rpId,
7889
+ userName: customer.email_hash.slice(0, 16),
7890
+ userDisplayName: customer.display_name,
7891
+ attestationType: "none",
7892
+ excludeCredentials: exclude,
7893
+ });
7894
+ _setChallengeCookie(res, {
7895
+ kind: "add",
7896
+ customer_id: customer.id,
7897
+ challenge: startOpts.challenge,
7898
+ created_at: Date.now(),
7899
+ });
7900
+ res.status(200);
7901
+ res.setHeader && res.setHeader("content-type", "application/json");
7902
+ return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
7903
+ } catch (e) {
7904
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
7905
+ res.status(e instanceof TypeError ? 400 : 500);
7906
+ return res.end ? res.end((e && e.message) || "add-begin failed") : res.send((e && e.message) || "add-begin failed");
7907
+ }
7908
+ });
7909
+
7910
+ router.post("/account/passkey/add-finish", async function (req, res) {
7911
+ var auth = _accountAuth(req, res); if (!auth) return;
7912
+ try {
7913
+ var env = _readChallengeEnv(req);
7914
+ if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
7915
+ if (env.kind !== "add") { res.status(400); return res.end ? res.end("bad challenge") : res.send("bad challenge"); }
7916
+ // The challenge must belong to the customer driving this request —
7917
+ // a stolen "add" challenge can't enroll a credential elsewhere.
7918
+ if (env.customer_id !== auth.customer_id) {
7919
+ res.status(403); return res.end ? res.end("challenge / account mismatch") : res.send("challenge / account mismatch");
7920
+ }
7921
+ var att = _readJsonBody(req);
7922
+ var rv = await b.auth.passkey.verifyRegistration({
7923
+ response: att,
7924
+ expectedChallenge: env.challenge,
7925
+ expectedOrigin: expectedOrigin,
7926
+ expectedRPID: rpId,
7927
+ });
7928
+ if (!rv || !rv.verified) {
7929
+ res.status(400); return res.end ? res.end("attestation refused") : res.send("attestation refused");
7930
+ }
7931
+ var info = rv.registrationInfo || {};
7932
+ var credentialId = info.credentialID || att.rawId || att.id;
7933
+ var publicKey = info.credentialPublicKey;
7934
+ if (credentialId && typeof credentialId !== "string") credentialId = _b64u(credentialId);
7935
+ if (publicKey && typeof publicKey !== "string") publicKey = _b64u(publicKey);
7936
+ var transports = "";
7937
+ if (att.response && Array.isArray(att.response.transports)) {
7938
+ transports = att.response.transports.filter(function (t) { return /^[a-z]+$/.test(t); }).join(",");
7939
+ }
7940
+ try {
7941
+ await deps.customers.addPasskey(env.customer_id, {
7942
+ credential_id: credentialId,
7943
+ public_key: publicKey,
7944
+ counter: info.counter || 0,
7945
+ transports: transports,
7946
+ });
7947
+ } catch (e) {
7948
+ if (e && e.code === "PASSKEY_DUPLICATE") {
7949
+ res.status(409); return res.end ? res.end("credential already registered") : res.send("credential already registered");
7950
+ }
7951
+ throw e;
7952
+ }
7953
+ _clearChallengeCookie(res);
7954
+ res.status(200);
7955
+ return res.end ? res.end("ok") : res.send("ok");
7956
+ } catch (e) {
7957
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
7958
+ res.status(e instanceof TypeError ? 400 : 500);
7959
+ return res.end ? res.end((e && e.message) || "add-finish failed") : res.send((e && e.message) || "add-finish failed");
7960
+ }
7961
+ });
7962
+
7963
+ // Profile edit — display-name only. Email is hash-only + the OAuth
7964
+ // linking key, so the primitive refuses an email change without a
7965
+ // verification ceremony; the form shows it read-only. PRG with a
7966
+ // ?ok=updated success notice, matching the addresses pattern.
7967
+ async function _renderProfilePage(req, res, auth, customer, notice, code) {
7968
+ var cartCount = await _cartCountForReq(req);
7969
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7970
+ var okKind = url ? url.searchParams.get("ok") : null;
7971
+ _send(res, code || 200, renderProfile({
7972
+ customer: customer,
7973
+ notice: notice || null,
7974
+ success: okKind === "updated" ? "Profile updated." : null,
7975
+ shop_name: shopName,
7976
+ cart_count: cartCount,
7977
+ }));
7978
+ }
7979
+
7980
+ router.get("/account/profile", async function (req, res) {
7981
+ var auth = _accountAuth(req, res); if (!auth) return;
7982
+ var customer = await deps.customers.get(auth.customer_id);
7983
+ if (!customer) {
7984
+ _clearAuthCookie(res);
7985
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7986
+ return res.end ? res.end() : res.send("");
7987
+ }
7988
+ await _renderProfilePage(req, res, auth, customer, null);
7989
+ });
7990
+
7991
+ router.post("/account/profile", async function (req, res) {
7992
+ var auth = _accountAuth(req, res); if (!auth) return;
7993
+ var customer = await deps.customers.get(auth.customer_id);
7994
+ if (!customer) {
7995
+ _clearAuthCookie(res);
7996
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7997
+ return res.end ? res.end() : res.send("");
7998
+ }
7999
+ var body = req.body || {};
8000
+ try {
8001
+ await deps.customers.update(auth.customer_id, { display_name: body.display_name });
8002
+ } catch (e) {
8003
+ if (e instanceof TypeError) {
8004
+ var merged = Object.assign({}, customer, { display_name: body.display_name });
8005
+ return _renderProfilePage(req, res, auth, merged, (e && e.message) || "Please check the form.", 400);
8006
+ }
8007
+ throw e;
8008
+ }
8009
+ res.status(303); res.setHeader && res.setHeader("location", "/account/profile?ok=updated");
8010
+ return res.end ? res.end() : res.send("");
8011
+ });
8012
+
7554
8013
  // Sign in with Google (OIDC). Mounts when the operator wires an
7555
8014
  // `oauthGoogle` adapter (b.auth.oauth, google preset). The framework
7556
8015
  // adapter owns discovery + PKCE + ID-token verification (signature,
@@ -9433,6 +9892,9 @@ module.exports = {
9433
9892
  renderAccountLogin: renderAccountLogin,
9434
9893
  renderAccountRegister: renderAccountRegister,
9435
9894
  renderAccount: renderAccount,
9895
+ renderPasskeys: renderPasskeys,
9896
+ renderPasskeyRemoveConfirm: renderPasskeyRemoveConfirm,
9897
+ renderProfile: renderProfile,
9436
9898
  renderAccountSubscriptions: renderAccountSubscriptions,
9437
9899
  renderCookiePreferences: renderCookiePreferences,
9438
9900
  renderNotFound: renderNotFound,
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.13.32",
7
- "tag": "v0.13.32",
6
+ "version": "0.13.42",
7
+ "tag": "v0.13.42",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,26 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.42 (2026-05-29) — **S/MIME trust-chain validation binds the leaf to the key that verified the signature.** When b.mail.crypto.smime.verify is given trust anchors, it validates the supplied certificate chain — but it picked the chain leaf unconditionally (the first cert) and never tied it to signerPublicKey, the key that actually verified the signature. A SignedData blob could therefore carry a validly-chained certificate for one identity while the signature was verified under an unrelated key, and the chain-validated result would imply a cert↔signer binding the code never made. Chain validation now selects the leaf as the certificate whose public key matches signerPublicKey, and refuses (signer-not-in-chain) when no certificate in the chain carries that key — so a chain-validated signature is bound to the cert it claims. **Security:** *Trust-chain leaf is bound to the verified signer key* — `smime.verify({ trustAnchorCertsPem })` validated the supplied chain starting from `chain[0]` without checking that the leaf's public key was the one that verified the signature (the operator-supplied `signerPublicKey`). A crafted `SignedData` could pair a validly-chained certificate for identity A with a signature verified under an unrelated key, and the chain-valid result would assert a binding that didn't hold. The chain walk now selects the leaf as the certificate whose public key equals `signerPublicKey` (matched against the certificate's SPKI, raw key or full-encoding form), and throws `mail-crypto/smime/signer-not-in-chain` when no certificate in `SignedData.certificates` carries that key. A certificate whose key cannot be extracted is treated as a non-match, so validation fails closed rather than trusting an unverifiable binding.
12
+
13
+ - v0.13.41 (2026-05-29) — **Agent registry reads can be tenant-scoped; compliance-erasure docs clarify actor is an audit field.** The agent orchestrator's registry reads (list and lookup) gated only on the flat agent-registry:read scope, so any holder could enumerate every tenant's agents and resolve a handle to one — even though the event bus already scopes subscribe and delivery by tenant. The orchestrator now mirrors that: with the new tenantScope option enabled, list returns only the actor's own tenant's agents and lookup refuses a cross-tenant name, unless the actor holds the framework cross-tenant-admin scope. Off by default, so single-tenant deployments are unaffected. Separately, the subject-erasure docs now state explicitly that the recorded actor is an audit field, not authentication — the caller must be authorized upstream. **Added:** *Tenant-scoped agent registry reads (opts.tenantScope)* — `b.agent.orchestrator.create({ tenantScope: true })` now scopes `list` and `lookup` to the calling actor's tenant: `list` filters out agents in other tenants and `lookup` returns null for a cross-tenant name, unless the actor holds the cross-tenant-admin scope (`b.agent.tenant.CROSS_TENANT_ADMIN_SCOPE`). This closes a cross-tenant metadata-enumeration and handle-acquisition path — `agent-registry:read` alone no longer exposes other tenants' agents — and mirrors the tenant scoping the event bus enforces on subscribe and delivery. The option defaults off; existing single-tenant orchestrators behave exactly as before. The `tenantId` argument to `list` remains a convenience filter, distinct from this authorization boundary. **Changed:** *Subject-erasure docs clarify the actor is an audit field, not authentication* — `b.subject.erase` and `b.subject.eraseHard` gate the deletion on operator acknowledgements and the legal-hold registry, not on caller identity. Their documentation now states explicitly that the recorded `actor` is an audit-record field, not authentication — the caller MUST be authenticated and authorized by the route before invoking. No behavior change; this removes an implicit assumption that could otherwise be read as the primitive authorizing the call.
14
+
15
+ - v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
16
+
17
+ - v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
18
+
19
+ - v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
20
+
21
+ - v0.13.37 (2026-05-29) — **Encrypted-mode DB refuses writes before a full tmpfs corrupts it.** In encrypted-at-rest mode the live SQLite working copy is on a tmpfs (Docker's /dev/shm defaults to 64 MiB). If that fills — an append-only audit chain and session rows grow over time — SQLite hits ENOSPC and the working copy is corrupted. Earlier releases made that corruption self-heal on the next boot by rolling back to the last encrypted snapshot, but the rollback still loses writes since the last flush. This adds the prevention side: a periodic free-space probe refuses growth writes (INSERT / UPDATE / REPLACE) with a clear db/storage-low error once free space drops below a threshold, before the tmpfs fills. DELETE and reads stay available so retention can reclaim space and the application keeps serving, and the refusal lifts automatically once free space recovers. The threshold defaults to 16 MiB of headroom and is tunable; the guard is encrypted-mode only. **Security:** *Free-space guard refuses growth writes before the tmpfs working copy fills* — `b.db` in encrypted-at-rest mode now probes free space on the tmpfs holding the working copy and, when it falls below `minFreeBytes` (default 16 MiB), refuses `INSERT` / `UPDATE` / `REPLACE` with a clear `db/storage-low` error instead of letting the write run the mount out of space and corrupt the database. `DELETE`, reads, and DDL stay available so retention can prune and the app keeps serving; the refusal clears automatically when free space recovers. The error message points at the cause (Docker's 64 MiB `/dev/shm` default) and the fix (`shm_size` / `--shm-size`, or pruning). Set `minFreeBytes` to tune the headroom, or `0` to disable. This complements the existing boot-time recovery: prevention (fail clear, keep recent writes) ahead of recovery (roll back to the last snapshot).
22
+
23
+ - v0.13.36 (2026-05-29) — **Certificate renewal trusts the sealed cert's own expiry, not the plaintext index.** The managed-certificate renewal check decided a cached cert was still fresh by reading expiresAt from the plaintext meta.json index that sits beside the sealed cert, rather than from the certificate itself. If that index drifted from — or was tampered relative to — the actual cert (a far-future expiry recorded over a certificate that is in fact near expiry), the manager would skip renewal and keep serving a cert that was about to expire or already had. Renewal now re-derives the expiry and fingerprint from the sealed certificate itself; meta.json is treated as an advisory convenience copy. A sealed cert that no longer parses is re-issued (the same recovery as an unreadable one), and a corrupt meta.json over a valid cert now loads cleanly from the cert instead of forcing a needless re-issue. The local job queue also bounds the size of a job payload it parses back from a stored row, matching the cap the dead-letter listing already used. **Fixed:** *Local job queue bounds the size of a payload parsed back from a stored row* — When the local queue leased a job or re-enqueued a repeating one, it parsed the job payload back from its stored row without an upper size bound, unlike the dead-letter listing which already capped it. A row with an oversized payload (a corrupted or tampered store) could force an unbounded parse. Both paths now cap the parse at the same 64 MiB ceiling the dead-letter path uses. **Security:** *Cert renewal re-derives expiry from the sealed certificate, not the meta.json index* — `b.cert`'s renewal short-circuit read `expiresAt` from the plaintext `meta.json` written beside each sealed cert. Because that index can drift from the actual certificate (or be altered independently of it), a far-future value over an actually-expiring cert would suppress renewal and serve a cert past — or about to pass — its validity. The renewal decision now parses the expiry and fingerprint from the sealed certificate itself on load, so `meta.json` is advisory only. A sealed cert that will not parse is treated as corrupt and re-issued; a corrupt `meta.json` over an otherwise-valid cert loads from the cert without a needless re-issue.
24
+
25
+ - v0.13.35 (2026-05-29) — **In-memory replay, idempotency, DNS, and i18n stores gain entry-count ceilings.** Several framework caches keyed on request-influenced input grew without an upper bound between their periodic sweeps, so a flood of unique keys could exhaust process memory faster than the sweep reclaimed it. Each now enforces a hard entry ceiling. The replay-protection nonce store is the security-sensitive one: rather than evict a live nonce to admit a new one — which would reopen a replay window for the evicted nonce — it purges expired entries and then fails closed at capacity, refusing the unrecordable request instead of admitting it unprotected. The idempotency, DNS, and i18n caches hold re-derivable values, so they evict the oldest entry instead (the worst case is a recomputed value or a single re-executed retry under flood). Ceilings are generous defaults that normal traffic never reaches; the nonce store and the agent idempotency in-memory backend expose options to tune them. **Fixed:** *Agent idempotency in-memory backend no longer grows without bound* — The default in-memory backend for `b.agent.idempotency` is keyed on the request-supplied idempotency key, and its garbage collector only reclaims expired rows when an operator wires a scheduler to call it — so a flood of distinct keys could grow it until the process ran out of memory. It now caps its entry count and evicts oldest-first; a dropped record just means that one key re-executes on a later retry, never a crash. A new `maxInMemoryEntries` option tunes the ceiling (default 100,000); deployments needing a hard guarantee at scale still supply a durable `store`. · *DNS resolver cache is bounded* — The positive and negative resolver caches in `b.network.dns` reclaimed an expired entry only when the same hostname was looked up again, so entries for never-requeried hostnames persisted — and hostnames reaching the resolver are request-influenced (outbound request targets, mail MX lookups). Both caches now cap their entry count and evict oldest-first; DNS simply re-resolves on the next miss. · *i18n formatter cache is bounded* — Per-instance `Intl` formatter caches in `b.i18n` are keyed on the locale plus a hash of the format options. The format-options shape is open-ended and caller-supplied, so the key space was request-influenced and uncapped. The cache now enforces an entry ceiling and evicts oldest-first — a formatter is pure-derived and re-created on the next miss. **Security:** *Replay-nonce store bounds memory and fails closed under a nonce flood* — The in-memory `b.nonceStore` backend recorded every request-supplied nonce until a periodic sweep ran, so a stream of unique nonces could exhaust memory between sweeps (a memory-amplification denial of service). It now caps its entry count. Because a replay-protection store must never evict a live nonce to make room — doing so would reopen a replay window for the evicted nonce — it instead purges expired entries inline and, if still at capacity with live nonces, fails closed: the new request is refused rather than admitted without replay protection. A new `maxEntries` option tunes the ceiling (default 1,000,000).
26
+
27
+ - v0.13.34 (2026-05-29) — **Corrupt TLS certs self-heal at boot, and graceful shutdown no longer loses the final DB flush.** Two failure-mode fixes in the same family as the encrypted-DB recovery in 0.13.33. The cert manager treated a corrupt sealed cert or key worse than a missing one: a missing file re-issues via ACME, but a corrupt one let a raw decrypt error escape out of start(), so the same bad file was read on every boot — an unrecoverable crash loop. A corrupt sealed cert/key is now treated like an absent one and re-issued, and a corrupt derived meta.json is re-derived rather than fatal; the ACME account key (which binds order history) instead fails with an actionable error rather than a raw throw. On the shutdown side, an encrypted database that failed its final re-encrypt used to delete its plaintext working copy anyway, discarding every write since the last periodic flush; it now keeps the working copy so the next boot recovers it. The shutdown orchestrator also gains a hard-deadline watchdog: when the operator delegates signal handling to it, a phase that never settles can no longer hold the process open until the supervisor SIGKILLs it (which would skip the final DB re-encrypt) — the watchdog forces a clean exit, so exit handlers still flush. The wiki production and base compose files set a stop_grace_period above that budget so a docker stop or rolling redeploy lets the re-encrypt finish. **Changed:** *Shutdown watchdog forces a clean, DB-flushing exit if a phase hangs* — The graceful-shutdown orchestrator uses soft per-phase timeouts — on expiry the underlying work keeps running — so a phase that never settles could hold the event loop open past the grace window, after which a container supervisor SIGKILLs the process and skips the final DB re-encrypt. When the operator opts into signal handling, a watchdog now forces `process.exit` `graceMs + forceExitMarginMs` after the signal; exit runs the registered handlers (the DB re-encrypt), so the last flush still happens. A new `forceExitMarginMs` option (default 5000) tunes the headroom; set the container stop grace above `graceMs + forceExitMarginMs`. · *Wiki compose sets stop_grace_period above the shutdown budget* — `examples/wiki/docker-compose.yml` and `docker-compose.prod.yml` now set `stop_grace_period: 40s`. Docker's 10s default would SIGKILL the container before the 30s shutdown budget reaches the DB re-encrypt phase, losing the final flush on a `docker stop` or rolling redeploy. The production note also reminds PaaS platforms that regenerate the compose (Coolify, Dokku, CapRover) to set the stop grace via the platform UI alongside the persistent-storage mount and `--shm-size`. **Fixed:** *A corrupt sealed TLS cert or key re-issues instead of crash-looping at boot* — `b.cert`'s start path read the sealed `cert.pem`/`key.pem` and let a raw unseal/decrypt error escape if the file was truncated or corrupt, so a managed restart read the same bad file on every boot — a crash loop, and worse handling than an absent file (which already re-issues). A corrupt sealed cert/key is now treated like a missing one: it is logged, an audit event is emitted, and the certificate is re-issued via ACME. A corrupt derived `meta.json` is likewise re-derived rather than throwing `cert/bad-meta`. · *Unreadable ACME account key fails with an actionable error, not a raw decrypt throw* — Unlike a re-issuable certificate, the ACME account key binds existing order and authorization history, so it is not silently regenerated on corruption. An unreadable `account/jwk.json.sealed` now raises `cert/account-key-unreadable` naming the file and the recovery (restore from backup, or delete to register a fresh account) instead of letting a raw decrypt/parse error escape out of start(). · *Encrypted DB keeps its working copy when the final shutdown re-encrypt fails* — `db.close()` re-encrypts the tmpfs working copy to `db.enc`, then deletes the working copy. If that final re-encrypt failed (a full `/dev/shm`, a full disk), the delete still ran, discarding every write since the last periodic flush and leaving only the older `db.enc`. The working copy is now kept whenever the re-encrypt fails, so the next boot's integrity-probed recovery picks up the latest writes (and still falls back to `db.enc` if the working copy is itself corrupt). `db.enc` is never modified by this path. **Detectors:** *Cross-artifact guard that stop_grace_period covers the shutdown budget* — A new codebase check fails if either wiki compose file omits `stop_grace_period` or sets it below the orchestrator's `graceMs` plus the watchdog margin read from `lib/app-shutdown.js`, so raising the shutdown budget without bumping the compose — or dropping the setting — cannot silently reopen the SIGKILL-before-re-encrypt data-loss window.
28
+
29
+ - v0.13.33 (2026-05-28) — **Encrypted-mode DB recovers from a corrupt tmpfs working copy instead of crash-looping.** In encrypted-at-rest mode the live SQLite copy is decrypted into a tmpfs working file and re-encrypted to db.enc periodically. If that working copy was corrupted (an unclean shutdown, or a full tmpfs — Docker's /dev/shm defaults to 64 MiB), the boot path trusted it because its mtime was newer than db.enc, so db.init failed its integrity gate with "database disk image is malformed" identically on every boot — an unrecoverable crash loop. db now integrity-probes the newer working copy before trusting it: if it is unreadable, the working copy is discarded and db.enc (the last-good encrypted snapshot) is re-decrypted, so the next boot self-heals. db.enc is never modified by this path, and a genuinely-corrupt db.enc still fails loudly rather than wiping data. The boot error on an unreadable database is now actionable (it names the tmpfs-size cause and the recovery). The wiki production compose also gains the storage settings encrypted mode needs. **Fixed:** *Corrupt tmpfs working copy no longer causes a boot crash loop (encrypted-at-rest mode)* — `db.init`'s crash-recovery path preferred a newer tmpfs working copy over `db.enc` unconditionally. When that copy was corrupt (truncated by an unclean shutdown or a full `/dev/shm`), every boot trusted it and failed the integrity gate the same way — an unrecoverable loop. The newer working copy is now integrity-probed (`PRAGMA quick_check`); if it is unreadable it is discarded and `db.enc` — the last-good encrypted snapshot — is re-decrypted, so boot self-heals. `db.enc` is never modified, so this only ever rolls back to the persistent copy; if `db.enc` is also corrupt, boot still fails loudly (no silent data loss). A regression test pins the recovery. · *Actionable boot error when the database is unreadable* — When SQLite reports a database too corrupt to even run an integrity check, the boot error now names the likely cause and recovery instead of surfacing the raw "database disk image is malformed": in encrypted mode it points at the tmpfs working copy and the most common operational cause (Docker's 64 MiB `/dev/shm` default — raise it via `shm_size` / `--shm-size`), or restoring `db.enc` / the DB file from backup. · *Wiki production compose ships the storage encrypted mode needs* — `examples/wiki/docker-compose.prod.yml` now sets `shm_size: '512m'` (so the encrypted-mode tmpfs working copy has headroom above Docker's 64 MiB default) and mounts a persistent `wiki-data` volume at `/data` (so `db.enc` + sealed keys survive container recreate, host reboot, and image redeploys, and give a restore point). A note flags that PaaS platforms which regenerate the compose on deploy (Coolify, Dokku, CapRover, …) must set both via the platform UI — a persistent-storage mount for `/data` and a `--shm-size 512m` custom option.
30
+
11
31
  - v0.13.32 (2026-05-28) — **`b.auditDailyReview` enforces notify under the sox-404 posture; compliance doc corrections.** b.auditDailyReview documented `sox-404` (SOX §404 ICFR — the internal-controls regime this primitive serves) as one of the postures that make a `notify` callback mandatory at construction, but the enforcement set used only `sox`, so pinning `posture: "sox-404"` without a notify channel was silently accepted. `sox-404` is now in the mandatory-notify set, so the advertised guarantee holds (a regression test pins it). The rest are documentation corrections with no behavior change: b.compliance.posturesByDomain / posturesByJurisdiction examples showed small fixed arrays where the functions return every matching posture (the catalog has grown); b.dataAct's surface list named two methods that do not exist (the real surface is declareProduct / recordUserAccess / shareWithThirdParty / recordSwitchRequest, with gatekeeper refusal folded into shareWithThirdParty); b.secCyber.eightKArtifact's documented return key `audit` is actually `deadlineBusinessDays`; and b.compliance.aiAct.transparency's helper summary named `cspMetaTag` / a `watermark({ kind })` argument that are really `metaTags` / `watermark({ mediaKind })`. **Fixed:** *`b.auditDailyReview` requires a notify channel under the `sox-404` posture* — The docs listed `sox-404` among the postures that make a `notify` callback mandatory at create-time, but the enforcement set contained only `sox` — so `posture: "sox-404"` without `notify` was accepted instead of refused. `sox-404` (SOX §404 ICFR) is now in the mandatory-notify set, matching the documented guarantee; constructing without a notify channel under it throws `auditDailyReview/notify-required-under-posture`. · *`b.compliance` jurisdiction/domain lister examples no longer enumerate a stale fixed set* — `posturesByDomain` and `posturesByJurisdiction` return every posture matching the domain/jurisdiction, but their `@example`s showed small fixed arrays from before the posture catalog grew. The examples now show a representative prefix with `...` and note they return the full matching set. · *`b.dataAct` surface list matches the real methods* — The module surface listed `userAccessible(...)` and `refuseGatekeeper(...)`, neither of which exists. The real surface is `declareProduct` / `recordUserAccess` / `shareWithThirdParty` / `recordSwitchRequest`, and DMA-gatekeeper refusal (Art 32 §1) is enforced inside `shareWithThirdParty`. The doc now reflects that. · *`b.secCyber.eightKArtifact` documented return shape corrected* — The signature line showed `{ artifact, deadline, audit }`; the function returns `{ artifact, deadline, deadlineBusinessDays }` (there is no `audit` key). The doc now matches. · *`b.compliance.aiAct.transparency` helper names corrected* — The helper summary named a `cspMetaTag(...)` function and a `watermark({ kind })` argument; the real names are `metaTags(...)` and `watermark({ mediaKind })`. Calling the documented names threw. Also corrected: a `b.aiAdverseDecision` illustration showed an ECOA `statutoryDeadlines` shape that didn't match the regime's actual deadlines.
12
32
 
13
33
  - v0.13.31 (2026-05-28) — **Circuit-breaker onStateChange callback now fires; mcp / vault-aad doc corrections.** b.circuitBreaker documented an `onStateChange` callback (both an option and an `onStateChange(handler)` registration method) plus a state-change payload, but the callback was never invoked — only an observability event fired. The callback is now implemented: it fires on every transition with `{ name, from, to, at }`, the registration method works, and a non-function handler is rejected at construction. The same primitive's docs are corrected to name the real accessor (`getState()`, not `state()`) and drop a never-read `audit` option. Plus two doc-only corrections: b.mcp.toolResult.sanitize described composing b.guardHtml / b.ai.input.classify (it uses built-in detection) and documented a `classifyInput` option it never read; and b.vault.aad's prose said HKDF-SHAKE256 where the derivation is SHAKE256 (the AEAD AAD-binding itself is unchanged and sound). **Fixed:** *`b.circuitBreaker` onStateChange callback is invoked on every transition* — The `onStateChange` option and the `onStateChange(handler)` registration method are now wired: each registered handler is called with `{ name, from, to, at }` on every state transition (closed→open, open→half, half→closed/open), alongside the existing `breaker.state.change` observability event. A non-function `onStateChange` is rejected at construction. Previously the documented callback never fired. The docs are also corrected to name the real state accessor `getState()` (there is a `state` property, so `state()` was never a method) and to drop a never-read `audit` option. · *`b.mcp.toolResult.sanitize` documents its actual detection and options* — The prose said the sanitizer composes `b.guardHtml`'s strict profile and `b.ai.input.classify`; it uses built-in dangerous-HTML and prompt-injection-marker detection. The `@opts` also listed a `classifyInput` override the function never read. The prose now describes the built-in detection and the unwired `classifyInput` option is removed. The fail-closed refusal behavior (default `posture: "refuse"`) is unchanged. · *`b.vault.aad` derivation named correctly (SHAKE256)* — The module prose described the per-binding key derivation as HKDF-SHAKE256; it is SHAKE256 over the vault root concatenated with the binding inputs (no HKDF extract/expand). The AEAD AAD-binding to (table, row, column, schema version) — the file's actual security guarantee — is unchanged and sound; only the KDF name in the doc was wrong.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.32",
4
- "createdAt": "2026-05-29T01:45:58.287Z",
3
+ "frameworkVersion": "0.13.42",
4
+ "createdAt": "2026-05-29T18:46:45.251Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -6425,6 +6425,10 @@
6425
6425
  "tableName": {
6426
6426
  "type": "function",
6427
6427
  "arity": 1
6428
+ },
6429
+ "transaction": {
6430
+ "type": "function",
6431
+ "arity": 1
6428
6432
  }
6429
6433
  }
6430
6434
  },