@blamejs/blamejs-shop 0.1.20 → 0.1.23

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -3
  3. package/SECURITY.md +12 -0
  4. package/lib/admin.js +1035 -138
  5. package/lib/checkout.js +192 -22
  6. package/lib/collections.js +4 -3
  7. package/lib/customers.js +102 -0
  8. package/lib/giftcards.js +40 -0
  9. package/lib/order.js +22 -0
  10. package/lib/storefront.js +400 -53
  11. package/lib/vendor/MANIFEST.json +3 -3
  12. package/lib/vendor/blamejs/CHANGELOG.md +10 -0
  13. package/lib/vendor/blamejs/README.md +4 -0
  14. package/lib/vendor/blamejs/api-snapshot.json +129 -2
  15. package/lib/vendor/blamejs/index.js +12 -0
  16. package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
  17. package/lib/vendor/blamejs/lib/json-patch.js +206 -0
  18. package/lib/vendor/blamejs/lib/json-path.js +638 -0
  19. package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
  20. package/lib/vendor/blamejs/lib/jtd.js +234 -0
  21. package/lib/vendor/blamejs/lib/link-header.js +169 -0
  22. package/lib/vendor/blamejs/package.json +1 -1
  23. package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
  28. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
  34. package/package.json +4 -2
package/lib/storefront.js CHANGED
@@ -86,7 +86,7 @@ var LAYOUT =
86
86
  " <title>{{title}} — {{shop_name}}</title>\n" +
87
87
  " <meta name=\"description\" content=\"{{og_description}}\">\n" +
88
88
  " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
89
- " <link rel=\"stylesheet\" href=\"{{theme_css}}\">\n" +
89
+ " <link rel=\"stylesheet\" href=\"{{theme_css}}\"RAW_CSS_INTEGRITY>\n" +
90
90
  " <meta property=\"og:type\" content=\"{{og_type}}\">\n" +
91
91
  " <meta property=\"og:site_name\" content=\"{{shop_name}}\">\n" +
92
92
  " <meta property=\"og:title\" content=\"{{og_title}}\">\n" +
@@ -211,10 +211,57 @@ var LAYOUT =
211
211
  // forces every active session to re-fetch.
212
212
  var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + require("../package.json").version;
213
213
 
214
+ // Footer copyright year — resolved once at module load. It's a near-static
215
+ // value (changes once a year); a `new Date()` allocation on every page
216
+ // render was wasteful. Containers are long-lived and restart often enough
217
+ // that a year-boundary staleness window doesn't matter for a copyright line.
218
+ var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
219
+
220
+ // Client "island" scripts are served as external assets (same `?v=` cache-
221
+ // bust as the CSS), never inline — the storefront's strict `script-src
222
+ // 'self'` CSP blocks inline <script>, so an inline island silently fails
223
+ // in production. `'self'` allows these /assets/ files; the R2 asset sync
224
+ // (npm run sync-assets) uploads them alongside the stylesheets.
225
+ var _ASSET_JS_VERSION = require("../package.json").version;
226
+ var _assetFs = require("node:fs"); // allow:non-shop-require — one sync read of a bundled in-repo asset to compute its SRI hash via b.crypto.sri (the primitive needs the bytes); no remote/write, and b.staticServe.integrity is async so can't run at module load
227
+
228
+ // Subresource Integrity for the static assets. `b.crypto.sri` (SHA-384,
229
+ // W3C SRI 1.0) hashes the exact bytes the asset ships with; the browser
230
+ // refuses to run/apply the resource if the served bytes don't match — a
231
+ // defense against an R2/edge compromise or on-path injection. Computed
232
+ // once per asset from the in-repo file (the same bytes the deploy's asset
233
+ // sync uploads to R2), cached, and emitted as the `integrity` attribute.
234
+ // Same-origin, so no `crossorigin` is required for the check to run.
235
+ var _SRI_CACHE = {};
236
+ function _assetSri(relUnderThemeAssets) {
237
+ if (Object.prototype.hasOwnProperty.call(_SRI_CACHE, relUnderThemeAssets)) {
238
+ return _SRI_CACHE[relUnderThemeAssets];
239
+ }
240
+ var sri = null;
241
+ try {
242
+ // Forward slashes resolve cross-platform for fs reads; avoids a
243
+ // node:path require for one join.
244
+ var p = __dirname + "/../themes/default/assets/" + relUnderThemeAssets;
245
+ sri = b.crypto.sri(_assetFs.readFileSync(p), { algorithm: "sha384" });
246
+ } catch (_e) { sri = null; } // asset absent (e.g. unit-test cwd) → omit integrity, don't crash render
247
+ _SRI_CACHE[relUnderThemeAssets] = sri;
248
+ return sri;
249
+ }
250
+ function _islandScript(name) {
251
+ var sri = _assetSri("js/" + name);
252
+ return "<script src=\"/assets/themes/default/js/" + name + "?v=" + _ASSET_JS_VERSION + "\"" +
253
+ (sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
254
+ }
255
+
214
256
  function _wrap(opts) {
215
257
  var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
216
258
  ? opts.theme_css
217
259
  : DEFAULT_THEME_CSS_URL;
260
+ // SRI for the stylesheet — only for the default theme CSS we ship (and
261
+ // can hash); a custom operator-supplied `theme_css` is their asset with
262
+ // an unknown body, so it's left without an integrity attribute.
263
+ var cssSri = (themeCss === DEFAULT_THEME_CSS_URL) ? _assetSri("css/main.css") : null;
264
+ var themeCssIntegrity = cssSri ? " integrity=\"" + cssSri + "\"" : "";
218
265
  // OpenGraph / Twitter Card defaults — every page sets reasonable
219
266
  // fallbacks; per-page renderers (PDP, etc.) can override via
220
267
  // `opts.og_*` for a product-specific share preview.
@@ -228,16 +275,16 @@ function _wrap(opts) {
228
275
  title: opts.title,
229
276
  shop_name: shopName,
230
277
  cart_count: opts.cart_count == null ? 0 : opts.cart_count,
231
- year: String(new Date().getUTCFullYear()),
278
+ year: _COPYRIGHT_YEAR,
232
279
  search_q: opts.search_q == null ? "" : opts.search_q,
233
- theme_css: themeCss,
280
+ theme_css: themeCss,
234
281
  og_type: ogType,
235
282
  og_title: ogTitle,
236
283
  og_description: ogDescription,
237
284
  og_image: ogImage,
238
285
  og_url: ogUrl,
239
286
  body: "RAW_BODY_PLACEHOLDER",
240
- }).replace("RAW_BODY_PLACEHOLDER", opts.body);
287
+ }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity).replace("RAW_BODY_PLACEHOLDER", opts.body);
241
288
  // The body is RAW HTML (already rendered + escaped at the
242
289
  // per-fragment level). The placeholder swap is post-render so the
243
290
  // outer renderer's HTML-escape doesn't double-escape the inner
@@ -1371,6 +1418,107 @@ function renderReturns(opts) {
1371
1418
  });
1372
1419
  }
1373
1420
 
1421
+ // Subscription status pill — mirrors `_returnStatusBadge`. The status
1422
+ // string is one of Stripe's enum values (active, trialing, past_due,
1423
+ // canceled, …), surfaced as a CSS-classed badge the theme can style.
1424
+ function _subscriptionStatusBadge(status) {
1425
+ return "<span class=\"subscription-status subscription-status--" + b.template.escapeHtml(String(status)) + "\">" +
1426
+ b.template.escapeHtml(String(status)) + "</span>";
1427
+ }
1428
+
1429
+ // A subscription is cancelable from the storefront while it's still
1430
+ // billing — active or trialing, and not already wound down at period
1431
+ // end. A canceled / past_due / incomplete row shows no Cancel control.
1432
+ var CANCELABLE_SUB_STATUSES = ["active", "trialing", "past_due"];
1433
+ function _subscriptionIsCancelable(sub) {
1434
+ return CANCELABLE_SUB_STATUSES.indexOf(sub.status) !== -1 && Number(sub.cancel_at_period_end) !== 1;
1435
+ }
1436
+
1437
+ // Customer-facing subscription list. `opts.subscriptions` is an array of
1438
+ // subscription rows, each optionally carrying a resolved `plan` (joined
1439
+ // by the route). `opts.can_cancel` is false when the deploy has no
1440
+ // payment handle wired — the list renders read-only with a note, since
1441
+ // cancel composes Stripe. Empty state points at the catalog (creation is
1442
+ // a separate Stripe-subscription-checkout surface, not built here).
1443
+ function renderAccountSubscriptions(opts) {
1444
+ var esc = b.template.escapeHtml;
1445
+ var subs = opts.subscriptions || [];
1446
+ var canCancel = opts.can_cancel !== false;
1447
+ var rowsHtml = "";
1448
+ for (var i = 0; i < subs.length; i += 1) {
1449
+ var s = subs[i];
1450
+ var plan = s.plan || null;
1451
+ var planSummary = "";
1452
+ if (plan) {
1453
+ var every = Number(plan.interval_count) > 1
1454
+ ? "every " + Number(plan.interval_count) + " " + esc(String(plan.interval)) + "s"
1455
+ : "per " + esc(String(plan.interval));
1456
+ // Plans mirror Stripe's lowercase currency; pricing.format wants
1457
+ // the uppercase ISO 4217 code. A malformed currency / amount on a
1458
+ // row falls back to the interval-only summary rather than throwing
1459
+ // out of the renderer.
1460
+ var ccy = String(plan.currency || "usd").toUpperCase();
1461
+ var priceStr;
1462
+ try { priceStr = esc(pricing.format(Number(plan.amount_minor), ccy)) + " "; }
1463
+ catch (_e) { priceStr = ""; }
1464
+ planSummary = priceStr + every;
1465
+ } else {
1466
+ planSummary = "Plan unavailable";
1467
+ }
1468
+ var periodEnd = s.current_period_end
1469
+ ? new Date(Number(s.current_period_end)).toISOString().slice(0, 10)
1470
+ : "";
1471
+ var renewalNote = "";
1472
+ if (Number(s.cancel_at_period_end) === 1 && periodEnd) {
1473
+ renewalNote = "Ends <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
1474
+ } else if (periodEnd) {
1475
+ renewalNote = "Renews <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
1476
+ }
1477
+ var cancelControl = "";
1478
+ if (canCancel && _subscriptionIsCancelable(s)) {
1479
+ cancelControl =
1480
+ "<form class=\"subscription-card__cancel\" method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
1481
+ "<label class=\"subscription-card__when\">" +
1482
+ "<input type=\"checkbox\" name=\"immediate\" value=\"1\"> Cancel immediately (skip the rest of the period)" +
1483
+ "</label>" +
1484
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel subscription</button>" +
1485
+ "</form>";
1486
+ }
1487
+ rowsHtml +=
1488
+ "<li class=\"subscription-card\">" +
1489
+ "<div class=\"subscription-card__head\">" +
1490
+ "<span class=\"subscription-card__plan\">" + planSummary + "</span>" +
1491
+ _subscriptionStatusBadge(s.status) +
1492
+ "</div>" +
1493
+ (renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
1494
+ cancelControl +
1495
+ "</li>";
1496
+ }
1497
+ var note = canCancel
1498
+ ? ""
1499
+ : "<p class=\"subscription-note\">Cancellation isn't available on this store yet. Contact support to make changes.</p>";
1500
+ var inner = rowsHtml
1501
+ ? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
1502
+ : "<p class=\"subscription-empty\">You have no active subscriptions.</p>";
1503
+ var body =
1504
+ "<section class=\"account-subscriptions\">" +
1505
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1506
+ "<li><a href=\"/account\">Account</a></li>" +
1507
+ "<li aria-current=\"page\">Subscriptions</li>" +
1508
+ "</ol></nav>" +
1509
+ "<h1 class=\"account-subscriptions__title\">Subscriptions</h1>" +
1510
+ (opts.notice ? "<p class=\"form-notice\" role=\"status\">" + esc(String(opts.notice)) + "</p>" : "") +
1511
+ inner +
1512
+ "</section>";
1513
+ return _wrap({
1514
+ title: "Subscriptions",
1515
+ shop_name: opts.shop_name || "blamejs.shop",
1516
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1517
+ theme_css: opts.theme_css,
1518
+ body: body,
1519
+ });
1520
+ }
1521
+
1374
1522
  // Product-level "Save to wishlist" control + social-proof count.
1375
1523
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
1376
1524
  // so both paths emit identical markup. Action-only label — the toggle
@@ -1593,6 +1741,7 @@ var CHECKOUT_PAGE =
1593
1741
  " <label class=\"form-field\"><span class=\"form-field__label\">State / Region</span><input type=\"text\" name=\"state\" maxlength=\"5\" autocomplete=\"address-level1\" class=\"form-field__input--xs\"></label>\n" +
1594
1742
  " <label class=\"form-field\"><span class=\"form-field__label\">Postal code</span><input type=\"text\" name=\"postal\" maxlength=\"16\" autocomplete=\"postal-code\" class=\"form-field__input--sm\"></label>\n" +
1595
1743
  " </div>\n" +
1744
+ " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code <span class=\"small\">(optional)</span></span><input type=\"text\" name=\"gift_card_code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\"></label></div>\n" +
1596
1745
  " <div class=\"checkout-summary\">\n" +
1597
1746
  " <h3>Order summary</h3>\n" +
1598
1747
  " <dl>\n" +
@@ -1638,6 +1787,63 @@ function renderCheckoutForm(opts) {
1638
1787
  });
1639
1788
  }
1640
1789
 
1790
+ // ---- gift-card balance check (customer-facing) -------------------------
1791
+ //
1792
+ // A bearer gift-card code is private — the page never confirms whether a
1793
+ // code "exists". A recognized active card shows its balance; anything else
1794
+ // (unknown, malformed, expired, voided, redeemed) shows the same generic
1795
+ // "couldn't find a balance" message so the page is not a code-probing
1796
+ // oracle. Server-rendered; the result re-renders the same page with the
1797
+ // outcome inline.
1798
+ var GIFT_CARD_PAGE =
1799
+ "<section class=\"checkout-page\" style=\"max-width:32rem;margin:0 auto;\">\n" +
1800
+ " <header class=\"section-head\">\n" +
1801
+ " <p class=\"eyebrow\">Gift cards</p>\n" +
1802
+ " <h1 class=\"section-head__title\">Check a gift card balance</h1>\n" +
1803
+ " <p class=\"section-head__lede\">Enter the code printed on your gift card to see what's left to spend. Apply it at checkout.</p>\n" +
1804
+ " </header>\n" +
1805
+ " RAW_RESULT" +
1806
+ " <form method=\"post\" action=\"/gift-cards/balance\" class=\"form-stack\">\n" +
1807
+ " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code</span><input type=\"text\" name=\"code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\" required></label></div>\n" +
1808
+ " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Check balance</button></div>\n" +
1809
+ " </form>\n" +
1810
+ "</section>\n";
1811
+
1812
+ function renderGiftCardBalance(opts) {
1813
+ opts = opts || {};
1814
+ var shopName = opts.shop_name || "blamejs.shop";
1815
+ var resultHtml = "";
1816
+ if (opts.balance != null) {
1817
+ var bal = pricing.format(opts.balance.balance_minor, opts.balance.currency);
1818
+ resultHtml =
1819
+ "<div class=\"checkout-summary\"><h3>Balance</h3><dl>" +
1820
+ "<div class=\"checkout-summary__total\"><dt>Available</dt><dd>" + b.template.escapeHtml(bal) + "</dd></div>" +
1821
+ "</dl></div>";
1822
+ } else if (opts.not_found) {
1823
+ resultHtml =
1824
+ "<p class=\"auth-form__message auth-form__message--err\">" +
1825
+ "We couldn't find a balance for that code. Check the characters and try again." +
1826
+ "</p>";
1827
+ }
1828
+ if (opts.theme) {
1829
+ return opts.theme.render("gift-card-balance", {
1830
+ title: "Gift card balance",
1831
+ shop_name: shopName,
1832
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1833
+ result: resultHtml,
1834
+ asset_css_main: opts.theme.assetUrl("css/main.css"),
1835
+ });
1836
+ }
1837
+ var body = GIFT_CARD_PAGE.replace("RAW_RESULT", resultHtml);
1838
+ return _wrap({
1839
+ title: "Gift card balance",
1840
+ shop_name: shopName,
1841
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1842
+ theme_css: opts.theme_css,
1843
+ body: body,
1844
+ });
1845
+ }
1846
+
1641
1847
  function _paypalCheckoutBlock(clientId, currency) {
1642
1848
  var esc = b.template.escapeHtml;
1643
1849
  var cid = esc(String(clientId));
@@ -2287,28 +2493,7 @@ var ACCOUNT_LOGIN_PAGE =
2287
2493
  " RAW_LOGIN_OAUTH\n" +
2288
2494
  " <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
2289
2495
  " </div>\n" +
2290
- " <script>\n" +
2291
- " (function () {\n" +
2292
- " function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
2293
- " function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
2294
- " var form=document.getElementById('login-form');\n" +
2295
- " var msg=document.getElementById('login-message');\n" +
2296
- " form.addEventListener('submit', async function(ev){\n" +
2297
- " ev.preventDefault(); msg.textContent='Requesting challenge...';\n" +
2298
- " try {\n" +
2299
- " var beginR = await fetch('/account/passkey/login-begin', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify({ email: document.getElementById('email').value }) });\n" +
2300
- " if (!beginR.ok) { msg.textContent = 'Sign-in unavailable.'; return; }\n" +
2301
- " var options = await beginR.json();\n" +
2302
- " options.challenge = _b64uToBuf(options.challenge);\n" +
2303
- " if (options.allowCredentials) options.allowCredentials = options.allowCredentials.map(function(c){ return Object.assign({}, c, { id: _b64uToBuf(c.id) }); });\n" +
2304
- " var assertion = await navigator.credentials.get({ publicKey: options });\n" +
2305
- " var payload = { id: assertion.id, rawId: _bufToB64u(assertion.rawId), type: assertion.type, response: { authenticatorData: _bufToB64u(assertion.response.authenticatorData), clientDataJSON: _bufToB64u(assertion.response.clientDataJSON), signature: _bufToB64u(assertion.response.signature), userHandle: assertion.response.userHandle ? _bufToB64u(assertion.response.userHandle) : null } };\n" +
2306
- " var finishR = await fetch('/account/passkey/login-finish', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });\n" +
2307
- " if (finishR.ok) { window.location.href = '/account'; } else { msg.textContent = 'Sign-in failed.'; }\n" +
2308
- " } catch (e) { msg.textContent = (e && e.message) || 'Sign-in error.'; }\n" +
2309
- " });\n" +
2310
- " })();\n" +
2311
- " </script>\n" +
2496
+ " RAW_LOGIN_SCRIPT\n" +
2312
2497
  "</section>\n";
2313
2498
 
2314
2499
  var LOGIN_ERROR_MESSAGES = {
@@ -2336,7 +2521,8 @@ function renderAccountLogin(opts) {
2336
2521
  : "";
2337
2522
  var body = ACCOUNT_LOGIN_PAGE
2338
2523
  .replace("RAW_LOGIN_OAUTH", oauthHtml)
2339
- .replace("RAW_LOGIN_ERROR", errHtml);
2524
+ .replace("RAW_LOGIN_ERROR", errHtml)
2525
+ .replace("RAW_LOGIN_SCRIPT", _islandScript("passkey-login.js"));
2340
2526
  return _wrap({
2341
2527
  title: "Sign in",
2342
2528
  shop_name: opts.shop_name || "blamejs.shop",
@@ -2360,29 +2546,7 @@ var ACCOUNT_REGISTER_PAGE =
2360
2546
  " </form>\n" +
2361
2547
  " <p class=\"auth-card__alt\">Already have one? <a href=\"/account/login\">Sign in →</a></p>\n" +
2362
2548
  " </div>\n" +
2363
- " <script>\n" +
2364
- " (function () {\n" +
2365
- " function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
2366
- " function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
2367
- " var form=document.getElementById('reg-form');\n" +
2368
- " var msg=document.getElementById('reg-message');\n" +
2369
- " form.addEventListener('submit', async function(ev){\n" +
2370
- " ev.preventDefault(); msg.textContent='Creating account...';\n" +
2371
- " try {\n" +
2372
- " var beginR = await fetch('/account/passkey/register-begin', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify({ email: document.getElementById('email').value, display_name: document.getElementById('display_name').value }) });\n" +
2373
- " if (!beginR.ok) { msg.textContent = (await beginR.text()) || 'Registration unavailable.'; return; }\n" +
2374
- " var options = await beginR.json();\n" +
2375
- " options.challenge = _b64uToBuf(options.challenge);\n" +
2376
- " options.user.id = _b64uToBuf(options.user.id);\n" +
2377
- " if (options.excludeCredentials) options.excludeCredentials = options.excludeCredentials.map(function(c){ return Object.assign({}, c, { id: _b64uToBuf(c.id) }); });\n" +
2378
- " var att = await navigator.credentials.create({ publicKey: options });\n" +
2379
- " var payload = { id: att.id, rawId: _bufToB64u(att.rawId), type: att.type, response: { attestationObject: _bufToB64u(att.response.attestationObject), clientDataJSON: _bufToB64u(att.response.clientDataJSON), transports: att.response.getTransports ? att.response.getTransports() : [] } };\n" +
2380
- " var finishR = await fetch('/account/passkey/register-finish', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });\n" +
2381
- " if (finishR.ok) { window.location.href = '/account'; } else { msg.textContent = (await finishR.text()) || 'Enrollment failed.'; }\n" +
2382
- " } catch (e) { msg.textContent = (e && e.message) || 'Registration error.'; }\n" +
2383
- " });\n" +
2384
- " })();\n" +
2385
- " </script>\n" +
2549
+ " RAW_REGISTER_SCRIPT\n" +
2386
2550
  "</section>\n";
2387
2551
 
2388
2552
  function renderAccountRegister(opts) {
@@ -2392,7 +2556,7 @@ function renderAccountRegister(opts) {
2392
2556
  shop_name: opts.shop_name || "blamejs.shop",
2393
2557
  cart_count: opts.cart_count,
2394
2558
  theme_css: opts.theme_css,
2395
- body: ACCOUNT_REGISTER_PAGE,
2559
+ body: ACCOUNT_REGISTER_PAGE.replace("RAW_REGISTER_SCRIPT", _islandScript("passkey-register.js")),
2396
2560
  });
2397
2561
  }
2398
2562
 
@@ -2410,6 +2574,7 @@ var ACCOUNT_DASH_PAGE =
2410
2574
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
2411
2575
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2412
2576
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2577
+ " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
2413
2578
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
2414
2579
  " </div>\n" +
2415
2580
  " </header>\n" +
@@ -2523,6 +2688,10 @@ function mount(router, deps) {
2523
2688
  // the inline-string templates above stay in force (operators on
2524
2689
  // older deploys keep their current look without a migration step).
2525
2690
  var theme = deps.theme || null;
2691
+ // Subscription self-management — opts in /account/subscriptions. The
2692
+ // cancel route additionally needs the payment handle (cancel composes
2693
+ // Stripe via the primitive); without it the list stays read-only.
2694
+ var subscriptions = deps.subscriptions || null;
2526
2695
 
2527
2696
  function _send(res, status, html) {
2528
2697
  res.status(status);
@@ -2898,8 +3067,17 @@ function mount(router, deps) {
2898
3067
  ship_to: shipTo,
2899
3068
  selected_shipping_id: defaultShipId || "std",
2900
3069
  customer: { email: body.email, name: body.name },
3070
+ gift_card_code: body.gift_card_code || undefined,
2901
3071
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
2902
3072
  });
3073
+ // When a gift card fully covered the order there's no Stripe
3074
+ // intent — the order is already paid. Skip the pay-cookie +
3075
+ // pay page and land the customer straight on the confirmation.
3076
+ if (!result.payment_intent) {
3077
+ res.status(303);
3078
+ res.setHeader && res.setHeader("location", "/orders/" + result.order.id);
3079
+ return res.end ? res.end() : res.send("");
3080
+ }
2903
3081
  // Set a short-lived pay cookie so /pay/:order_id can serve the
2904
3082
  // client_secret without re-running confirm. Scoped to /pay/ +
2905
3083
  // SameSite=Strict so it's only ever sent to the pay route.
@@ -2910,7 +3088,12 @@ function mount(router, deps) {
2910
3088
  res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
2911
3089
  return res.end ? res.end() : res.send("");
2912
3090
  } catch (e) {
2913
- res.status(e instanceof TypeError ? 400 : 500);
3091
+ // A bad customer input (malformed shape OR a gift-card code the
3092
+ // customer typed that can't be applied) is a 400, not a 500 —
3093
+ // the gift-card errors carry a GIFTCARD_* code so a fat-fingered
3094
+ // code re-prompts rather than 500-ing the checkout.
3095
+ var clientErr = (e instanceof TypeError) || (e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0);
3096
+ res.status(clientErr ? 400 : 500);
2914
3097
  var msg = (e && e.message) || "checkout failed";
2915
3098
  return res.end ? res.end(msg) : res.send(msg);
2916
3099
  }
@@ -2948,14 +3131,22 @@ function mount(router, deps) {
2948
3131
  ship_to: shipTo,
2949
3132
  selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
2950
3133
  customer: { email: body.email, name: body.name },
3134
+ gift_card_code: body.gift_card_code || undefined,
2951
3135
  idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
2952
3136
  return_url: body.return_url || undefined,
2953
3137
  cancel_url: body.cancel_url || undefined,
2954
3138
  });
3139
+ // Gift card fully covered the order — no PayPal order id to
3140
+ // approve. Tell the button to redirect straight to the
3141
+ // confirmation page instead of opening the PayPal popup.
3142
+ if (!created.paypal_order_id) {
3143
+ return _json(200, { paid_by_gift_card: true, order_id: created.order.id, redirect: "/orders/" + created.order.id });
3144
+ }
2955
3145
  // The PayPal JS SDK's createOrder expects `{ id }`.
2956
3146
  return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
2957
3147
  } catch (e) {
2958
- return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
3148
+ var gcErr = e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0;
3149
+ return _json((e instanceof TypeError || gcErr) ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
2959
3150
  }
2960
3151
  });
2961
3152
 
@@ -3129,6 +3320,47 @@ function mount(router, deps) {
3129
3320
  }
3130
3321
  }
3131
3322
 
3323
+ // ---- gift-card balance check (customer-facing) ---------------------
3324
+ //
3325
+ // Mounts when deps.giftcards is wired. GET renders the lookup form;
3326
+ // POST looks the code up and re-renders with the balance (or a
3327
+ // generic not-found). The page is deliberately not a code-existence
3328
+ // oracle: unknown, malformed, expired, voided, and redeemed codes
3329
+ // all produce the same "couldn't find a balance" outcome.
3330
+ if (deps.giftcards) {
3331
+ router.get("/gift-cards", async function (req, res) {
3332
+ _send(res, 200, renderGiftCardBalance({
3333
+ shop_name: shopName, theme: theme, cart_count: await _cartCountForReq(req),
3334
+ }));
3335
+ });
3336
+
3337
+ router.post("/gift-cards/balance", async function (req, res) {
3338
+ var body = req.body || {};
3339
+ var code = typeof body.code === "string" ? body.code : "";
3340
+ var balance = null;
3341
+ var notFound = true;
3342
+ try {
3343
+ var view = await deps.giftcards.balance(code);
3344
+ // Only an active card with a positive balance surfaces a
3345
+ // number — an expired / voided / fully-redeemed card is the
3346
+ // same dead end as an unknown code to the customer.
3347
+ if (view && view.status === "active") {
3348
+ balance = { balance_minor: view.balance_minor, currency: view.currency };
3349
+ notFound = false;
3350
+ }
3351
+ } catch (e) {
3352
+ // A malformed code throws TypeError at the canonicalizer —
3353
+ // swallow it into the same generic not-found. Any other error
3354
+ // (e.g. a DB fault) bubbles so it isn't masked as not-found.
3355
+ if (!(e instanceof TypeError)) throw e;
3356
+ }
3357
+ _send(res, 200, renderGiftCardBalance({
3358
+ shop_name: shopName, theme: theme, cart_count: await _cartCountForReq(req),
3359
+ balance: balance, not_found: notFound,
3360
+ }));
3361
+ });
3362
+ }
3363
+
3132
3364
  // ---- customer accounts (passkey-only) ------------------------------
3133
3365
  //
3134
3366
  // Mount only when deps.customers is supplied (operator opts in by
@@ -3978,6 +4210,119 @@ function mount(router, deps) {
3978
4210
  _addrAction("archive", function (id) { return deps.addresses.archive(id); });
3979
4211
  }
3980
4212
 
4213
+ // Subscription self-management — the signed-in customer views their
4214
+ // own recurring subscriptions and cancels them. The list mounts on
4215
+ // the subscriptions primitive alone; the cancel route additionally
4216
+ // needs the payment handle (cancel composes Stripe through
4217
+ // `subscriptions.subscriptions.cancel`, mirroring the admin gate). A
4218
+ // subscription is fetched + ownership-checked against the authed
4219
+ // customer before any cancel — a guessed/forged id never reaches
4220
+ // another customer's row. Creation (a Stripe subscription-checkout
4221
+ // flow) is a separate surface; this is the management view.
4222
+ if (subscriptions) {
4223
+ var subsCanCancel = !!deps.payment;
4224
+
4225
+ function _subsAuth(req, res) {
4226
+ var auth;
4227
+ try { auth = _currentCustomer(req); }
4228
+ catch (e) {
4229
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
4230
+ throw e;
4231
+ }
4232
+ if (!auth) {
4233
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
4234
+ res.end ? res.end() : res.send("");
4235
+ return null;
4236
+ }
4237
+ return auth;
4238
+ }
4239
+
4240
+ // Resolve the customer's subscriptions and join each to its plan.
4241
+ // Plans are batched: one `plans.get` per distinct plan_id, cached
4242
+ // across rows, so a customer with many subscriptions on the same
4243
+ // plan costs one plan read, not one per subscription (no N+1). A
4244
+ // read failure (table not migrated) degrades to an empty list
4245
+ // rather than 500-ing the account page.
4246
+ async function _subsForCustomer(customerId) {
4247
+ var rows;
4248
+ try { rows = await subscriptions.subscriptions.list({ customer_id: customerId }); }
4249
+ catch (e) {
4250
+ if (e instanceof TypeError) return [];
4251
+ throw e;
4252
+ }
4253
+ var planCache = {};
4254
+ for (var i = 0; i < rows.length; i += 1) {
4255
+ var pid = rows[i].plan_id;
4256
+ if (pid != null && !Object.prototype.hasOwnProperty.call(planCache, pid)) {
4257
+ try { planCache[pid] = await subscriptions.plans.get(pid); }
4258
+ catch (_e) { planCache[pid] = null; }
4259
+ }
4260
+ rows[i].plan = pid != null ? planCache[pid] : null;
4261
+ }
4262
+ return rows;
4263
+ }
4264
+
4265
+ // Load the subscription named in :id and confirm it belongs to the
4266
+ // signed-in customer. A malformed id (guardUuid TypeError), a
4267
+ // missing row, or another customer's subscription all return null
4268
+ // after sending a 404 — never a 500, never a cross-customer cancel.
4269
+ async function _ownedSubscription(req, res, auth) {
4270
+ var sub;
4271
+ try { sub = await subscriptions.subscriptions.get(req.params && req.params.id); }
4272
+ catch (e) {
4273
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
4274
+ throw e;
4275
+ }
4276
+ if (!sub || sub.customer_id !== auth.customer_id) {
4277
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
4278
+ return null;
4279
+ }
4280
+ return sub;
4281
+ }
4282
+
4283
+ router.get("/account/subscriptions", async function (req, res) {
4284
+ var auth = _subsAuth(req, res); if (!auth) return;
4285
+ var rows = await _subsForCustomer(auth.customer_id);
4286
+ var cartCount = await _cartCountForReq(req);
4287
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4288
+ var canceled = !!(url && url.searchParams.get("canceled"));
4289
+ _send(res, 200, renderAccountSubscriptions({
4290
+ subscriptions: rows,
4291
+ can_cancel: subsCanCancel,
4292
+ notice: canceled ? "Your subscription has been canceled." : null,
4293
+ shop_name: shopName,
4294
+ cart_count: cartCount,
4295
+ }));
4296
+ });
4297
+
4298
+ // Cancel mounts only when payment is wired (cancel composes Stripe).
4299
+ // Without payment the list above stays read-only with a note.
4300
+ if (deps.payment) {
4301
+ router.post("/account/subscriptions/:id/cancel", async function (req, res) {
4302
+ var auth = _subsAuth(req, res); if (!auth) return;
4303
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
4304
+ // Default cancel-at-period-end (the customer keeps access through
4305
+ // the period they've paid for); the form opts into immediate via
4306
+ // the `immediate` checkbox.
4307
+ var atPeriodEnd = !((req.body || {}).immediate === "1");
4308
+ try {
4309
+ await subscriptions.subscriptions.cancel(sub.id, { at_period_end: atPeriodEnd });
4310
+ } catch (e) {
4311
+ // A bad id was already screened by _ownedSubscription; a
4312
+ // downstream TypeError (shape) bounces back to the list with a
4313
+ // notice rather than 500-ing.
4314
+ if (e instanceof TypeError) {
4315
+ res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
4316
+ return res.end ? res.end() : res.send("");
4317
+ }
4318
+ throw e;
4319
+ }
4320
+ res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions?canceled=1");
4321
+ return res.end ? res.end() : res.send("");
4322
+ });
4323
+ }
4324
+ }
4325
+
3981
4326
  // Self-serve returns — a customer requests an RMA against one of
3982
4327
  // their own orders and tracks its status. Operators action it via
3983
4328
  // the admin /admin/returns queue. Needs the returns primitive + an
@@ -4415,11 +4760,13 @@ module.exports = {
4415
4760
  renderProduct: renderProduct,
4416
4761
  renderCart: renderCart,
4417
4762
  renderCheckoutForm: renderCheckoutForm,
4763
+ renderGiftCardBalance: renderGiftCardBalance,
4418
4764
  renderPayPage: renderPayPage,
4419
4765
  renderOrder: renderOrder,
4420
4766
  renderAccountLogin: renderAccountLogin,
4421
4767
  renderAccountRegister: renderAccountRegister,
4422
4768
  renderAccount: renderAccount,
4769
+ renderAccountSubscriptions: renderAccountSubscriptions,
4423
4770
  renderNotFound: renderNotFound,
4424
4771
  // Layout exposed so operators forking the framework can override.
4425
4772
  _wrap: _wrap,
@@ -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.12.56",
7
- "tag": "v0.12.56",
6
+ "version": "0.12.62",
7
+ "tag": "v0.12.62",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -13,7 +13,7 @@
13
13
  "server": "lib/vendor/blamejs/"
14
14
  },
15
15
  "bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
16
- "bundledAt": "2026-05-25"
16
+ "bundledAt": "2026-05-26"
17
17
  }
18
18
  }
19
19
  }